From 438c63c2c7617bf60cb4f6b5fbdb71ec71bddd7f Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Thu, 6 Aug 2026 18:02:12 +0900 Subject: [PATCH 1/4] Prevent sandbox mounts from exposing credentials --- .../references/sandbox-runtime-boundary.md | 2 +- examples/sandbox/README.md | 2 +- .../docker/mounts/azure_mount_read_write.py | 29 -- .../docker/mounts/gcs_mount_read_write.py | 37 -- .../docker/mounts/s3_mount_read_write.py | 33 -- examples/sandbox/extensions/README.md | 6 +- .../extensions/daytona/daytona_runner.py | 3 - examples/sandbox/memory_s3.py | 4 +- .../extensions/sandbox/blaxel/mounts.py | 77 ++- .../extensions/sandbox/blaxel/sandbox.py | 3 +- .../extensions/sandbox/cloudflare/mounts.py | 3 +- .../extensions/sandbox/cloudflare/sandbox.py | 3 +- .../extensions/sandbox/daytona/mounts.py | 3 +- .../extensions/sandbox/daytona/sandbox.py | 3 +- src/agents/extensions/sandbox/e2b/mounts.py | 3 +- src/agents/extensions/sandbox/e2b/sandbox.py | 3 +- src/agents/extensions/sandbox/modal/mounts.py | 3 +- .../extensions/sandbox/modal/sandbox.py | 3 +- .../extensions/sandbox/runloop/mounts.py | 3 +- .../extensions/sandbox/runloop/sandbox.py | 3 +- .../extensions/sandbox/vercel/mounts.py | 3 +- .../extensions/sandbox/vercel/sandbox.py | 3 +- src/agents/run_state.py | 13 +- src/agents/sandbox/_mount_security.py | 477 ++++++++++++++++++ src/agents/sandbox/entries/mounts/base.py | 71 ++- src/agents/sandbox/entries/mounts/patterns.py | 9 + .../entries/mounts/providers/azure_blob.py | 3 +- .../sandbox/entries/mounts/providers/box.py | 11 +- .../sandbox/entries/mounts/providers/gcs.py | 11 +- .../sandbox/entries/mounts/providers/r2.py | 5 +- .../sandbox/entries/mounts/providers/s3.py | 5 +- src/agents/sandbox/manifest.py | 62 +++ src/agents/sandbox/runtime_session_manager.py | 27 +- src/agents/sandbox/sandboxes/docker.py | 3 +- src/agents/sandbox/sandboxes/unix_local.py | 3 +- .../sandbox/session/manifest_application.py | 2 + src/agents/sandbox/session/sandbox_client.py | 26 +- .../sandbox/session/sandbox_session_state.py | 72 ++- tests/extensions/sandbox/test_blaxel.py | 18 + .../extensions/sandbox/test_blaxel_mounts.py | 107 ++++ tests/extensions/sandbox/test_cloudflare.py | 1 + tests/extensions/sandbox/test_daytona.py | 4 + tests/extensions/sandbox/test_e2b.py | 1 + tests/extensions/sandbox/test_modal.py | 1 + .../extensions/sandbox/test_runloop_mounts.py | 1 + tests/extensions/sandbox/test_vercel.py | 1 + tests/sandbox/integration_tests/_helpers.py | 8 +- tests/sandbox/test_mount_security.py | 270 ++++++++++ tests/sandbox/test_mounts.py | 4 + tests/sandbox/test_runtime.py | 129 +++++ tests/sandbox/test_session_state_roundtrip.py | 357 ++++++++++++- tests/test_run_state.py | 406 +++++++++++++++ 52 files changed, 2179 insertions(+), 161 deletions(-) create mode 100644 src/agents/sandbox/_mount_security.py create mode 100644 tests/sandbox/test_mount_security.py diff --git a/.agents/references/sandbox-runtime-boundary.md b/.agents/references/sandbox-runtime-boundary.md index 221c7e16ec..a35e2b146e 100644 --- a/.agents/references/sandbox-runtime-boundary.md +++ b/.agents/references/sandbox-runtime-boundary.md @@ -35,7 +35,7 @@ Resolve the session source in this order: injected live session, resumable sandb - Validate local sources at use time, not only when parsing the manifest. Defend against symlinked sources, parent-directory swaps, platform path aliases, and archive members that change meaning between validation and extraction. - Archive extraction must reject traversal, unsafe links, and unsupported member types before writing, and enforce entry, byte, and expansion limits without materializing an unbounded member list. - Extra path grants are runtime access, not durable workspace content. Snapshots and `persist_workspace()` include the workspace root, not arbitrary granted paths. -- Credentials for mounts or providers must remain in the owning adapter and must not appear in generated shell commands, model-visible errors, logs, or serialized sandbox state. +- Credentials for mounts or providers must remain in the owning adapter and must not appear in generated shell commands, model-visible errors, logs, or serialized sandbox state. Reject explicit credentials before side effects when a mount strategy runs helpers inside the model-controlled sandbox. Credential-bearing mounts must use an external or provider-controlled strategy unless trusted application code explicitly acknowledges exposure for a specific path; manifest dictionaries and persisted state cannot carry that acknowledgement. ## Provider and Error Boundary diff --git a/examples/sandbox/README.md b/examples/sandbox/README.md index 733159a065..e841dad155 100644 --- a/examples/sandbox/README.md +++ b/examples/sandbox/README.md @@ -17,7 +17,7 @@ Most examples call a model through `Runner`, so set `OPENAI_API_KEY` in the repo | [`sandbox_agents_as_tools.py`](./sandbox_agents_as_tools.py) | `uv run python examples/sandbox/sandbox_agents_as_tools.py` | Exposes sandbox agents as tools for another agent. | | [`sandbox_agent_with_remote_snapshot.py`](./sandbox_agent_with_remote_snapshot.py) | `uv run python examples/sandbox/sandbox_agent_with_remote_snapshot.py` | Starts from a remote sandbox snapshot. | | [`memory.py`](./memory.py) | `uv run python examples/sandbox/memory.py` | Runs one sandbox agent twice across a snapshot resume so it can read and write its own memory. | -| [`memory_s3.py`](./memory_s3.py) | `source ~/.s3.env && uv run python examples/sandbox/memory_s3.py` | Runs sandbox memory across two fresh Docker sandboxes with S3-backed memory storage. | +| [`memory_s3.py`](./memory_s3.py) | `source ~/.s3.env && uv run python examples/sandbox/memory_s3.py` | Runs sandbox memory across two fresh Docker sandboxes with S3-backed memory storage attached by an external Docker volume driver. | | [`memory_multi_agent_multiturn.py`](./memory_multi_agent_multiturn.py) | `uv run python examples/sandbox/memory_multi_agent_multiturn.py` | Shows separate memory layouts for two agents sharing one sandbox workspace. | | [`unix_local_pty.py`](./unix_local_pty.py) | `uv run python examples/sandbox/unix_local_pty.py` | Exercises an interactive pseudo-terminal in a Unix-local sandbox. | | [`unix_local_runner.py`](./unix_local_runner.py) | `uv run python examples/sandbox/unix_local_runner.py` | Runs against the Unix-local sandbox backend directly. | diff --git a/examples/sandbox/docker/mounts/azure_mount_read_write.py b/examples/sandbox/docker/mounts/azure_mount_read_write.py index f29e5b9cdc..4cc7f07c18 100644 --- a/examples/sandbox/docker/mounts/azure_mount_read_write.py +++ b/examples/sandbox/docker/mounts/azure_mount_read_write.py @@ -11,9 +11,6 @@ from agents.sandbox.entries import ( AzureBlobMount, DockerVolumeMountStrategy, - FuseMountPattern, - InContainerMountStrategy, - RcloneMountPattern, ) from examples.sandbox.docker.mounts.mount_smoke import ( MountSmokeCase, @@ -43,32 +40,6 @@ def _mount_cases() -> list[MountSmokeCase]: read_only=False, ), ), - MountSmokeCase( - name="in_container/rclone", - mount_dir="azure-in-container-rclone", - mount=AzureBlobMount( - account=account, - container=container, - endpoint=endpoint, - identity_client_id=identity_client_id, - account_key=account_key, - mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), - read_only=False, - ), - ), - MountSmokeCase( - name="in_container/fuse", - mount_dir="azure-in-container-fuse", - mount=AzureBlobMount( - account=account, - container=container, - endpoint=endpoint, - identity_client_id=identity_client_id, - account_key=account_key, - mount_strategy=InContainerMountStrategy(pattern=FuseMountPattern()), - read_only=False, - ), - ), ] diff --git a/examples/sandbox/docker/mounts/gcs_mount_read_write.py b/examples/sandbox/docker/mounts/gcs_mount_read_write.py index d9cbc81ef7..adf94456a0 100644 --- a/examples/sandbox/docker/mounts/gcs_mount_read_write.py +++ b/examples/sandbox/docker/mounts/gcs_mount_read_write.py @@ -11,9 +11,6 @@ from agents.sandbox.entries import ( DockerVolumeMountStrategy, GCSMount, - InContainerMountStrategy, - MountpointMountPattern, - RcloneMountPattern, ) from examples.sandbox.docker.mounts.mount_smoke import ( MountSmokeCase, @@ -51,40 +48,6 @@ def _mount_cases() -> list[MountSmokeCase]: read_only=False, ), ), - MountSmokeCase( - name="in_container/rclone", - mount_dir="gcs-in-container-rclone", - mount=GCSMount( - bucket=bucket, - access_id=access_id, - secret_access_key=secret_access_key, - prefix=prefix, - region=region, - endpoint_url=endpoint_url, - service_account_file=service_account_file, - service_account_credentials=service_account_credentials, - access_token=access_token, - mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), - read_only=False, - ), - ), - MountSmokeCase( - name="in_container/mountpoint", - mount_dir="gcs-in-container-mountpoint", - mount=GCSMount( - bucket=bucket, - access_id=access_id, - secret_access_key=secret_access_key, - prefix=prefix, - region=region, - endpoint_url=endpoint_url, - service_account_file=service_account_file, - service_account_credentials=service_account_credentials, - access_token=access_token, - mount_strategy=InContainerMountStrategy(pattern=MountpointMountPattern()), - read_only=False, - ), - ), ] diff --git a/examples/sandbox/docker/mounts/s3_mount_read_write.py b/examples/sandbox/docker/mounts/s3_mount_read_write.py index 47b98089b8..4cfa6d2372 100644 --- a/examples/sandbox/docker/mounts/s3_mount_read_write.py +++ b/examples/sandbox/docker/mounts/s3_mount_read_write.py @@ -10,9 +10,6 @@ from agents.sandbox.entries import ( DockerVolumeMountStrategy, - InContainerMountStrategy, - MountpointMountPattern, - RcloneMountPattern, S3Mount, ) from examples.sandbox.docker.mounts.mount_smoke import ( @@ -40,36 +37,6 @@ def _mount_cases() -> list[MountSmokeCase]: read_only=False, ), ), - MountSmokeCase( - name="in_container/rclone", - mount_dir="s3-in-container-rclone", - mount=S3Mount( - bucket=bucket, - access_key_id=os.getenv("AWS_ACCESS_KEY_ID"), - secret_access_key=os.getenv("AWS_SECRET_ACCESS_KEY"), - session_token=os.getenv("AWS_SESSION_TOKEN"), - prefix=os.getenv("S3_MOUNT_PREFIX"), - region=os.getenv("AWS_REGION") or os.getenv("AWS_DEFAULT_REGION"), - endpoint_url=os.getenv("S3_ENDPOINT_URL"), - mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), - read_only=False, - ), - ), - MountSmokeCase( - name="in_container/mountpoint", - mount_dir="s3-in-container-mountpoint", - mount=S3Mount( - bucket=bucket, - access_key_id=os.getenv("AWS_ACCESS_KEY_ID"), - secret_access_key=os.getenv("AWS_SECRET_ACCESS_KEY"), - session_token=os.getenv("AWS_SESSION_TOKEN"), - prefix=os.getenv("S3_MOUNT_PREFIX"), - region=os.getenv("AWS_REGION") or os.getenv("AWS_DEFAULT_REGION"), - endpoint_url=os.getenv("S3_ENDPOINT_URL"), - mount_strategy=InContainerMountStrategy(pattern=MountpointMountPattern()), - read_only=False, - ), - ), ] diff --git a/examples/sandbox/extensions/README.md b/examples/sandbox/extensions/README.md index 7b5c3c0615..c53380bfc4 100644 --- a/examples/sandbox/extensions/README.md +++ b/examples/sandbox/extensions/README.md @@ -8,6 +8,8 @@ They intentionally keep the flow simple: 2. Create a `SandboxAgent` that inspects that workspace through one shell tool. 3. Run the agent against E2B, Modal, Daytona, Cloudflare, Runloop, Blaxel, or Vercel. +For cloud storage, prefer provider-native mounts or mounts established outside the sandbox. Hosted strategies that execute `rclone`, `s3fs`, `gcsfuse`, or similar helpers inside the model-controlled sandbox accept credentialless configurations, but explicit mount credentials are rejected by default. If a backend has no external or provider-native alternative, credential-bearing mounts are unsupported. + All of these examples require `OPENAI_API_KEY`, because they call the model through the normal `Runner` path. Each cloud backend also needs its own provider credentials. ## E2B @@ -228,6 +230,8 @@ export DAYTONA_API_KEY=... uv run python examples/sandbox/extensions/daytona/daytona_runner.py --stream ``` +The optional Daytona cloud-bucket flags demonstrate a credentialless in-sandbox mount. Do not pass AWS credential fields to `DaytonaCloudBucketMountStrategy`; use a backend with an external or provider-native mount mechanism when explicit credentials are required. + ## Runloop ### Setup @@ -350,4 +354,4 @@ The runner also includes standalone demos for individual features. Pass - `pty` -- agent-driven interactive Python session via PTY - `drive` -- [Blaxel Drive mount](https://docs.blaxel.ai/Agent-drive/Overview) (persistent storage, requires `--drive-name`) -Blaxel sandboxes support cloud bucket mounts (S3, R2, GCS) through `BlaxelCloudBucketMountStrategy` and persistent drive mounts through `BlaxelDriveMountStrategy`. See the [Blaxel Drive docs](https://docs.blaxel.ai/Agent-drive/Overview) for details. +Blaxel sandboxes support credentialless cloud bucket mounts (S3, R2, GCS) through `BlaxelCloudBucketMountStrategy`. Explicit credentials are rejected by default because its FUSE helpers run inside the sandbox. Prefer persistent drive mounts through the provider-controlled `BlaxelDriveMountStrategy`; see the [Blaxel Drive docs](https://docs.blaxel.ai/Agent-drive/Overview) for details. diff --git a/examples/sandbox/extensions/daytona/daytona_runner.py b/examples/sandbox/extensions/daytona/daytona_runner.py index 277305afd2..e786d2ba2a 100644 --- a/examples/sandbox/extensions/daytona/daytona_runner.py +++ b/examples/sandbox/extensions/daytona/daytona_runner.py @@ -74,9 +74,6 @@ def _build_manifest( manifest.entries["cloud-bucket"] = S3Mount( bucket=cloud_bucket_name, - access_key_id=os.environ.get("AWS_ACCESS_KEY_ID"), - secret_access_key=os.environ.get("AWS_SECRET_ACCESS_KEY"), - session_token=os.environ.get("AWS_SESSION_TOKEN"), endpoint_url=cloud_bucket_endpoint_url, prefix=cloud_bucket_key_prefix, mount_path=Path(cloud_bucket_mount_path) if cloud_bucket_mount_path is not None else None, diff --git a/examples/sandbox/memory_s3.py b/examples/sandbox/memory_s3.py index 946ce56689..0016a236d8 100644 --- a/examples/sandbox/memory_s3.py +++ b/examples/sandbox/memory_s3.py @@ -18,7 +18,7 @@ SandboxRunConfig, ) from agents.sandbox.capabilities import Filesystem, Memory, Shell -from agents.sandbox.entries import File, InContainerMountStrategy, RcloneMountPattern, S3Mount +from agents.sandbox.entries import DockerVolumeMountStrategy, File, S3Mount from agents.sandbox.sandboxes.docker import ( DockerSandboxClient, DockerSandboxClientOptions, @@ -146,7 +146,7 @@ def _build_manifest( prefix=config.prefix, region=config.region, endpoint_url=config.endpoint_url, - mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), read_only=False, ), } diff --git a/src/agents/extensions/sandbox/blaxel/mounts.py b/src/agents/extensions/sandbox/blaxel/mounts.py index dba5ecbe40..adad1dff8c 100644 --- a/src/agents/extensions/sandbox/blaxel/mounts.py +++ b/src/agents/extensions/sandbox/blaxel/mounts.py @@ -17,13 +17,14 @@ from __future__ import annotations +import io import logging import shlex import uuid import warnings from dataclasses import dataclass from pathlib import Path -from typing import Any, Literal +from typing import Any, ClassVar, Literal from .... import _debug from ....logger import log_tool_action_warning @@ -69,6 +70,7 @@ class BlaxelCloudBucketMountStrategy(MountStrategyBase): ``fusermount`` or ``umount``. """ + credential_boundary: ClassVar[Literal["inside_sandbox"]] = "inside_sandbox" type: Literal["blaxel_cloud_bucket"] = "blaxel_cloud_bucket" def validate_mount(self, mount: Mount) -> None: @@ -133,6 +135,7 @@ def build_docker_volume_driver_config( # --------------------------------------------------------------------------- _INSTALL_RETRIES = 3 +_CREDENTIAL_DIRECTORY = Path(".sandbox-blaxel-mount-credentials") def _assert_blaxel_session(session: BaseSandboxSession) -> None: @@ -281,24 +284,60 @@ async def _ensure_tool(session: BaseSandboxSession, tool: str) -> None: await _install_tool(session, tool) +async def _write_mount_credential_file( + session: BaseSandboxSession, + *, + name: str, + content: str, +) -> str: + session_id = getattr(session.state, "session_id", None) + if not isinstance(session_id, uuid.UUID): + raise MountConfigError(message="Blaxel mount session is missing session_id") + + credential_dir = _CREDENTIAL_DIRECTORY / session_id.hex + session.register_persist_workspace_skip_path(_CREDENTIAL_DIRECTORY) + await session.mkdir(credential_dir, parents=True) + credential_path = credential_dir / name + normalized_path = session.normalize_path(credential_path) + credential_path_str = sandbox_path_str(normalized_path) + try: + await session.write(credential_path, io.BytesIO(content.encode("utf-8"))) + await session._exec_checked_nonzero("chmod", "0600", credential_path_str) + except BaseException: + await _remove_mount_credential_file(session, credential_path_str) + raise + return credential_path_str + + +async def _remove_mount_credential_file( + session: BaseSandboxSession, + credential_path: str, +) -> None: + await session._exec_checked_nonzero("rm", "-f", credential_path) + + +def _redact_sensitive_values(text: str, values: list[str | None]) -> str: + redacted = text + for value in sorted({value for value in values if value}, key=len, reverse=True): + redacted = redacted.replace(value, "REDACTED") + return redacted + + async def _mount_s3(session: BaseSandboxSession, config: BlaxelCloudBucketMountConfig) -> None: """Mount an S3 or R2 bucket using s3fs-fuse.""" await _ensure_tool(session, "s3fs") # Write credentials to a temp file. - cred_path = f"/tmp/s3fs-passwd-{uuid.uuid4().hex[:8]}" + cred_path = "" if config.access_key_id and config.secret_access_key: cred_content = f"{config.access_key_id}:{config.secret_access_key}" if config.session_token: cred_content += f":{config.session_token}" - await session.exec( - "sh", - "-c", - f"printf %s {shlex.quote(cred_content)} > {cred_path} && chmod 600 {cred_path}", + cred_path = await _write_mount_credential_file( + session, + name=f"s3fs-passwd-{uuid.uuid4().hex[:8]}", + content=cred_content, ) - else: - cred_path = "" - # Build the s3fs command. bucket = config.bucket if config.prefix: @@ -331,6 +370,10 @@ async def _mount_s3(session: BaseSandboxSession, config: BlaxelCloudBucketMountC result = await _exec(session, cmd, timeout=60) if result.exit_code != 0: stderr = result.stderr.decode("utf-8", errors="replace") if result.stderr else "" + stderr = _redact_sensitive_values( + stderr, + [config.access_key_id, config.secret_access_key, config.session_token], + ) raise MountConfigError( message="s3fs mount failed", context={"cmd": cmd, "exit_code": result.exit_code, "stderr": stderr}, @@ -338,7 +381,7 @@ async def _mount_s3(session: BaseSandboxSession, config: BlaxelCloudBucketMountC finally: # Clean up credentials file. if cred_path: - await _exec(session, f"rm -f {cred_path}") + await _remove_mount_credential_file(session, cred_path) async def _mount_gcs(session: BaseSandboxSession, config: BlaxelCloudBucketMountConfig) -> None: @@ -351,12 +394,10 @@ async def _mount_gcs(session: BaseSandboxSession, config: BlaxelCloudBucketMount # Write service account key if provided. key_path = "" if config.service_account_key: - key_path = f"/tmp/gcs-creds-{uuid.uuid4().hex[:8]}.json" - await session.exec( - "sh", - "-c", - f"printf %s {shlex.quote(config.service_account_key)} " - f"> {key_path} && chmod 600 {key_path}", + key_path = await _write_mount_credential_file( + session, + name=f"gcs-creds-{uuid.uuid4().hex[:8]}.json", + content=config.service_account_key, ) opts: list[str] = [] @@ -379,13 +420,14 @@ async def _mount_gcs(session: BaseSandboxSession, config: BlaxelCloudBucketMount result = await _exec(session, cmd, timeout=60) if result.exit_code != 0: stderr = result.stderr.decode("utf-8", errors="replace") if result.stderr else "" + stderr = _redact_sensitive_values(stderr, [config.service_account_key]) raise MountConfigError( message="gcsfuse mount failed", context={"cmd": cmd, "exit_code": result.exit_code, "stderr": stderr}, ) finally: if key_path: - await _exec(session, f"rm -f {key_path}") + await _remove_mount_credential_file(session, key_path) async def _mount_bucket(session: BaseSandboxSession, config: BlaxelCloudBucketMountConfig) -> None: @@ -541,6 +583,7 @@ class BlaxelDriveMountStrategy(MountStrategyBase): ) """ + credential_boundary: ClassVar[Literal["outside_sandbox"]] = "outside_sandbox" type: Literal["blaxel_drive"] = "blaxel_drive" def validate_mount(self, mount: Mount) -> None: diff --git a/src/agents/extensions/sandbox/blaxel/sandbox.py b/src/agents/extensions/sandbox/blaxel/sandbox.py index 97145e4563..9ee712282c 100644 --- a/src/agents/extensions/sandbox/blaxel/sandbox.py +++ b/src/agents/extensions/sandbox/blaxel/sandbox.py @@ -1065,6 +1065,7 @@ async def create( ) -> SandboxSession: if manifest is None: manifest = Manifest(root=DEFAULT_BLAXEL_WORKSPACE_ROOT) + self._validate_manifest_mount_credentials(manifest) timeouts_in = options.timeouts if isinstance(timeouts_in, BlaxelTimeouts): @@ -1145,7 +1146,7 @@ async def resume( """ if not isinstance(state, BlaxelSandboxSessionState): raise TypeError("BlaxelSandboxClient.resume expects a BlaxelSandboxSessionState") - state.assert_path_grants_rebound() + state.assert_trusted_manifest_rebound() SandboxInstance = _import_blaxel_sdk() blaxel_sandbox = None diff --git a/src/agents/extensions/sandbox/cloudflare/mounts.py b/src/agents/extensions/sandbox/cloudflare/mounts.py index b6dcee22f6..ff8b62ae61 100644 --- a/src/agents/extensions/sandbox/cloudflare/mounts.py +++ b/src/agents/extensions/sandbox/cloudflare/mounts.py @@ -2,7 +2,7 @@ from dataclasses import dataclass from pathlib import Path -from typing import Literal +from typing import ClassVar, Literal from ....sandbox.entries import GCSMount, Mount, R2Mount, S3Mount from ....sandbox.entries.mounts.base import MountStrategyBase @@ -40,6 +40,7 @@ def to_request_options(self) -> dict[str, object]: class CloudflareBucketMountStrategy(MountStrategyBase): + credential_boundary: ClassVar[Literal["outside_sandbox"]] = "outside_sandbox" type: Literal["cloudflare_bucket_mount"] = "cloudflare_bucket_mount" def validate_mount(self, mount: Mount) -> None: diff --git a/src/agents/extensions/sandbox/cloudflare/sandbox.py b/src/agents/extensions/sandbox/cloudflare/sandbox.py index d0a5f83d87..cf221baf76 100644 --- a/src/agents/extensions/sandbox/cloudflare/sandbox.py +++ b/src/agents/extensions/sandbox/cloudflare/sandbox.py @@ -1457,6 +1457,7 @@ async def create( if manifest is None: manifest = Manifest() + self._validate_manifest_mount_credentials(manifest) if manifest.root != "/workspace": raise ConfigurationError( message=( @@ -1506,7 +1507,7 @@ async def resume(self, state: SandboxSessionState) -> SandboxSession: raise TypeError( "CloudflareSandboxClient.resume expects a CloudflareSandboxSessionState" ) - state.assert_path_grants_rebound() + state.assert_trusted_manifest_rebound() inner = CloudflareSandboxSession.from_state( state, exec_timeout_s=self._exec_timeout_s, diff --git a/src/agents/extensions/sandbox/daytona/mounts.py b/src/agents/extensions/sandbox/daytona/mounts.py index 038473e70e..3bc5c1ddfa 100644 --- a/src/agents/extensions/sandbox/daytona/mounts.py +++ b/src/agents/extensions/sandbox/daytona/mounts.py @@ -11,7 +11,7 @@ import logging from pathlib import Path -from typing import Literal +from typing import ClassVar, Literal from ....sandbox.entries.mounts.base import InContainerMountStrategy, Mount, MountStrategyBase from ....sandbox.entries.mounts.patterns import RcloneMountPattern @@ -182,6 +182,7 @@ class DaytonaCloudBucketMountStrategy(MountStrategyBase): ) """ + credential_boundary: ClassVar[Literal["inside_sandbox"]] = "inside_sandbox" type: Literal["daytona_cloud_bucket"] = "daytona_cloud_bucket" pattern: RcloneMountPattern = RcloneMountPattern(mode="fuse") diff --git a/src/agents/extensions/sandbox/daytona/sandbox.py b/src/agents/extensions/sandbox/daytona/sandbox.py index 388685c61e..9b84e78581 100644 --- a/src/agents/extensions/sandbox/daytona/sandbox.py +++ b/src/agents/extensions/sandbox/daytona/sandbox.py @@ -1255,6 +1255,7 @@ async def create( ) -> SandboxSession: if manifest is None: manifest = Manifest(root=DEFAULT_DAYTONA_WORKSPACE_ROOT) + self._validate_manifest_mount_credentials(manifest) timeouts_in = options.timeouts if isinstance(timeouts_in, DaytonaSandboxTimeouts): @@ -1326,7 +1327,7 @@ async def resume( ) -> SandboxSession: if not isinstance(state, DaytonaSandboxSessionState): raise TypeError("DaytonaSandboxClient.resume expects a DaytonaSandboxSessionState") - state.assert_path_grants_rebound() + state.assert_trusted_manifest_rebound() daytona_sandbox = None reconnected = False diff --git a/src/agents/extensions/sandbox/e2b/mounts.py b/src/agents/extensions/sandbox/e2b/mounts.py index 94b0a3bbb4..f2465043ef 100644 --- a/src/agents/extensions/sandbox/e2b/mounts.py +++ b/src/agents/extensions/sandbox/e2b/mounts.py @@ -3,7 +3,7 @@ from __future__ import annotations from pathlib import Path -from typing import Literal +from typing import ClassVar, Literal from ....sandbox.entries.mounts.base import InContainerMountStrategy, Mount, MountStrategyBase from ....sandbox.entries.mounts.patterns import RcloneMountPattern @@ -63,6 +63,7 @@ def _assert_e2b_session(session: BaseSandboxSession) -> None: class E2BCloudBucketMountStrategy(MountStrategyBase): """Mount rclone-backed cloud storage in E2B sandboxes.""" + credential_boundary: ClassVar[Literal["inside_sandbox"]] = "inside_sandbox" type: Literal["e2b_cloud_bucket"] = "e2b_cloud_bucket" pattern: RcloneMountPattern = RcloneMountPattern(mode="fuse") diff --git a/src/agents/extensions/sandbox/e2b/sandbox.py b/src/agents/extensions/sandbox/e2b/sandbox.py index 036f136657..a29f501d0d 100644 --- a/src/agents/extensions/sandbox/e2b/sandbox.py +++ b/src/agents/extensions/sandbox/e2b/sandbox.py @@ -1693,6 +1693,7 @@ async def create( if options is None: raise ValueError("E2BSandboxClient.create requires options") manifest = manifest or Manifest() + self._validate_manifest_mount_credentials(manifest) sandbox_type = _coerce_sandbox_type(options.sandbox_type) @@ -1770,7 +1771,7 @@ async def resume( ) -> SandboxSession: if not isinstance(state, E2BSandboxSessionState): raise TypeError("E2BSandboxClient.resume expects an E2BSandboxSessionState") - state.assert_path_grants_rebound() + state.assert_trusted_manifest_rebound() sandbox_type = _coerce_sandbox_type(state.sandbox_type) SandboxClass = _import_sandbox_class(sandbox_type) diff --git a/src/agents/extensions/sandbox/modal/mounts.py b/src/agents/extensions/sandbox/modal/mounts.py index a7dcb74a99..dc42dc5ee3 100644 --- a/src/agents/extensions/sandbox/modal/mounts.py +++ b/src/agents/extensions/sandbox/modal/mounts.py @@ -2,7 +2,7 @@ from dataclasses import dataclass from pathlib import Path -from typing import Literal +from typing import ClassVar, Literal from ....sandbox.entries import GCSMount, Mount, R2Mount, S3Mount from ....sandbox.entries.mounts.base import MountStrategyBase @@ -25,6 +25,7 @@ class ModalCloudBucketMountConfig: class ModalCloudBucketMountStrategy(MountStrategyBase): + credential_boundary: ClassVar[Literal["outside_sandbox"]] = "outside_sandbox" type: Literal["modal_cloud_bucket"] = "modal_cloud_bucket" secret_name: str | None = None secret_environment_name: str | None = None diff --git a/src/agents/extensions/sandbox/modal/sandbox.py b/src/agents/extensions/sandbox/modal/sandbox.py index d3a3665885..cd64b54322 100644 --- a/src/agents/extensions/sandbox/modal/sandbox.py +++ b/src/agents/extensions/sandbox/modal/sandbox.py @@ -2003,6 +2003,7 @@ async def create( if options is None: raise ValueError("ModalSandboxClient.create requires options with app_name") manifest = manifest or Manifest() + self._validate_manifest_mount_credentials(manifest) app_name = options.app_name if not app_name: raise ValueError("ModalSandboxClient.create requires a valid app_name") @@ -2177,7 +2178,7 @@ async def resume( ) -> SandboxSession: if not isinstance(state, ModalSandboxSessionState): raise TypeError("ModalSandboxClient.resume expects a ModalSandboxSessionState") - state.assert_path_grants_rebound() + state.assert_trusted_manifest_rebound() inner = ModalSandboxSession.from_state(state) reconnected = await inner._ensure_sandbox() if reconnected: diff --git a/src/agents/extensions/sandbox/runloop/mounts.py b/src/agents/extensions/sandbox/runloop/mounts.py index 66116794c8..4685685570 100644 --- a/src/agents/extensions/sandbox/runloop/mounts.py +++ b/src/agents/extensions/sandbox/runloop/mounts.py @@ -3,7 +3,7 @@ from __future__ import annotations from pathlib import Path -from typing import Literal +from typing import ClassVar, Literal from ....sandbox.entries.mounts.base import InContainerMountStrategy, Mount, MountStrategyBase from ....sandbox.entries.mounts.patterns import RcloneMountPattern @@ -109,6 +109,7 @@ def _assert_runloop_session(session: BaseSandboxSession) -> None: class RunloopCloudBucketMountStrategy(MountStrategyBase): """Mount rclone-backed cloud storage in Runloop sandboxes.""" + credential_boundary: ClassVar[Literal["inside_sandbox"]] = "inside_sandbox" type: Literal["runloop_cloud_bucket"] = "runloop_cloud_bucket" pattern: RcloneMountPattern = RcloneMountPattern(mode="fuse") diff --git a/src/agents/extensions/sandbox/runloop/sandbox.py b/src/agents/extensions/sandbox/runloop/sandbox.py index 53a8bea05e..52e0d967cb 100644 --- a/src/agents/extensions/sandbox/runloop/sandbox.py +++ b/src/agents/extensions/sandbox/runloop/sandbox.py @@ -1578,6 +1578,7 @@ async def create( user_parameters = _normalize_runloop_user_parameters(resolved_options.user_parameters) manifest = manifest or Manifest(root=_default_runloop_manifest_root(user_parameters)) + self._validate_manifest_mount_credentials(manifest) _validate_runloop_manifest_root(manifest, user_parameters=user_parameters) timeouts_in = resolved_options.timeouts @@ -1673,7 +1674,7 @@ async def resume( """ if not isinstance(state, RunloopSandboxSessionState): raise TypeError("RunloopSandboxClient.resume expects a RunloopSandboxSessionState") - state.assert_path_grants_rebound() + state.assert_trusted_manifest_rebound() devbox = None reconnected = False diff --git a/src/agents/extensions/sandbox/vercel/mounts.py b/src/agents/extensions/sandbox/vercel/mounts.py index b11954f112..759b866795 100644 --- a/src/agents/extensions/sandbox/vercel/mounts.py +++ b/src/agents/extensions/sandbox/vercel/mounts.py @@ -5,7 +5,7 @@ import asyncio import shlex from pathlib import Path -from typing import Literal, NoReturn +from typing import ClassVar, Literal, NoReturn from ....sandbox.entries import Mount, S3Mount from ....sandbox.entries.mounts.base import MountStrategyBase @@ -477,6 +477,7 @@ class VercelCloudBucketMountStrategy(MountStrategyBase): Those exclusions keep the provider lifecycle auditable. """ + credential_boundary: ClassVar[Literal["client_opt_in"]] = "client_opt_in" type: Literal["vercel_cloud_bucket"] = "vercel_cloud_bucket" def validate_mount(self, mount: Mount) -> None: diff --git a/src/agents/extensions/sandbox/vercel/sandbox.py b/src/agents/extensions/sandbox/vercel/sandbox.py index 4da5fb4164..f85ad6fd18 100644 --- a/src/agents/extensions/sandbox/vercel/sandbox.py +++ b/src/agents/extensions/sandbox/vercel/sandbox.py @@ -1349,6 +1349,7 @@ async def create( options: VercelSandboxClientOptions, ) -> SandboxSession: resolved_manifest = _resolve_manifest_root(manifest) + self._validate_manifest_mount_credentials(resolved_manifest) if ( _manifest_has_vercel_s3_credentials(resolved_manifest) and not options.allow_s3_credential_exposure @@ -1414,7 +1415,7 @@ async def delete(self, session: SandboxSession) -> SandboxSession: async def resume(self, state: SandboxSessionState) -> SandboxSession: if not isinstance(state, VercelSandboxSessionState): raise TypeError("VercelSandboxClient.resume expects a VercelSandboxSessionState") - state.assert_path_grants_rebound() + state.assert_trusted_manifest_rebound() if state.s3_mounts_non_resumable or _vercel_s3_mounts(state.manifest): raise MountConfigError( message=( diff --git a/src/agents/run_state.py b/src/agents/run_state.py index 243a6d2c9e..e56726d920 100644 --- a/src/agents/run_state.py +++ b/src/agents/run_state.py @@ -98,6 +98,7 @@ ensure_programmatic_tool_call_parent, ensure_tool_caller_allowed, ) +from .sandbox._mount_security import sanitize_run_state_sandbox_mount_credentials from .sandbox.capabilities.capability import Capability from .sandbox.session.base_sandbox_session import BaseSandboxSession from .tool import ( @@ -150,7 +151,7 @@ # 3. to_json() always emits CURRENT_SCHEMA_VERSION. # 4. Forward compatibility is intentionally fail-fast (older SDKs reject newer or unsupported # versions). -CURRENT_SCHEMA_VERSION = "1.14" +CURRENT_SCHEMA_VERSION = "1.15" _PROGRAMMATIC_TOOL_CALLING_MIN_SCHEMA_VERSION = "1.13" _HOSTED_MCP_APPROVALS_MIN_SCHEMA_VERSION = "1.14" # Keep this mapping in chronological order. Every schema bump must add a one-line summary here. @@ -176,6 +177,7 @@ "flows." ), "1.14": "Scopes hosted MCP approvals and restored requests by server label.", + "1.15": "Redacts sandbox mount credentials and requires trusted rebind on resume.", } SUPPORTED_SCHEMA_VERSIONS = frozenset(SCHEMA_VERSION_SUMMARIES) @@ -900,7 +902,7 @@ def to_json( include_tracing_api_key=include_tracing_api_key ) if self._sandbox is not None: - result["sandbox"] = copy.deepcopy(self._sandbox) + result["sandbox"] = sanitize_run_state_sandbox_mount_credentials(self._sandbox) return result @@ -3081,7 +3083,12 @@ async def _build_run_state_from_json( else: state._trace_state = None sandbox_data = state_json.get("sandbox") - state._sandbox = dict(sandbox_data) if isinstance(sandbox_data, Mapping) else None + if sandbox_data is None: + state._sandbox = None + elif isinstance(sandbox_data, Mapping): + state._sandbox = sanitize_run_state_sandbox_mount_credentials(sandbox_data) + else: + raise ValueError("RunState sandbox must be an object") return state diff --git a/src/agents/sandbox/_mount_security.py b/src/agents/sandbox/_mount_security.py new file mode 100644 index 0000000000..f7b3cc538a --- /dev/null +++ b/src/agents/sandbox/_mount_security.py @@ -0,0 +1,477 @@ +from __future__ import annotations + +import copy +from collections.abc import Mapping +from pathlib import Path +from typing import Any, cast + +from .entries import Mount +from .entries.base import BaseEntry +from .errors import InvalidManifestPathError, MountConfigError +from .manifest import Manifest +from .workspace_paths import coerce_posix_path, posix_path_as_path + +REDACTED_MOUNT_CREDENTIAL_PATHS_KEY = "__openai_agents_redacted_mount_credential_paths" +_RUN_STATE_SANDBOX_FIELDS = frozenset( + { + "backend_id", + "current_agent_id", + "current_agent_key", + "current_agent_name", + "session_state", + "sessions_by_agent", + } +) + + +def validate_mount_credential_boundaries(manifest: Manifest) -> None: + """Reject credential-bearing mounts that can expose credentials inside the sandbox.""" + + for mount, mount_path in manifest.mount_targets(): + credential_fields = mount._configured_credential_fields() + if not credential_fields: + continue + + _reject_serialized_rclone_config_source(manifest, mount, mount_path) + + boundary = mount.mount_strategy.credential_boundary + if boundary in {"outside_sandbox", "client_opt_in"}: + continue + if manifest._allows_in_container_mount_credential_exposure(mount_path): + continue + + context = { + "mount_path": mount_path.as_posix(), + "mount_type": mount.type, + "mount_strategy": mount.mount_strategy.type, + "credential_fields": list(credential_fields), + } + if boundary == "inside_sandbox": + raise MountConfigError( + message=( + "mount credentials cannot be passed to a helper inside a model-controlled " + "sandbox by default; use an external or provider-native mount strategy, or " + "explicitly acknowledge exposure on a trusted Manifest instance" + ), + context=context, + ) + raise MountConfigError( + message=( + "mount strategy must declare its credential boundary before it can be used " + "with explicit credentials" + ), + context=context, + ) + + +def redact_manifest_mount_credentials(manifest: Manifest) -> tuple[Manifest, tuple[str, ...]]: + """Return a manifest copy without credentials and stable entry paths that need rebind.""" + + paths = tuple( + entry_path + for entry_path, mount in _mounts_by_entry_path(manifest).items() + if mount._configured_credential_fields() + ) + if not paths: + return manifest, () + + redacted = manifest.model_copy(deep=True) + redacted_mounts = _mounts_by_entry_path(redacted) + for path in paths: + mount = redacted_mounts.get(path) + if mount is None: + raise ValueError(f"Could not redact persisted mount credentials for {path}") + mount._clear_configured_credentials() + redacted._in_container_mount_credential_exposure_allowed_paths = frozenset() + return redacted, tuple(dict.fromkeys(paths)) + + +def sanitize_serialized_mount_credentials( + payload: dict[str, object], +) -> tuple[dict[str, object], tuple[str, ...]]: + """Strip mount credentials from a raw state payload before model validation.""" + + sanitized = dict(payload) + raw_manifest = sanitized.get("manifest") + if "manifest" in sanitized and not isinstance(raw_manifest, dict): + raise ValueError("Persisted sandbox manifest must be an object") + if not isinstance(raw_manifest, dict): + return sanitized, () + if { + "in_container_mount_credential_exposure_allowed_paths", + "_in_container_mount_credential_exposure_allowed_paths", + } & raw_manifest.keys(): + raise ValueError( + "Persisted sandbox manifest cannot configure mount credential exposure policy" + ) + manifest = dict(raw_manifest) + sanitized["manifest"] = manifest + raw_entries = manifest.get("entries") + if "entries" in manifest and not isinstance(raw_entries, dict): + raise ValueError("Persisted sandbox manifest entries must be an object") + if not isinstance(raw_entries, dict): + return sanitized, () + entries = copy.deepcopy(raw_entries) + manifest["entries"] = entries + entry_paths = _collect_raw_entry_paths(entries) + raw_root = manifest.get("root", "/workspace") + root = Path(raw_root) if isinstance(raw_root, str) else Path("/workspace") + + credential_paths: list[str] = [] + _sanitize_entry_mapping( + entries, + prefix="", + credential_paths=credential_paths, + entry_paths=entry_paths, + root=root, + ) + return sanitized, tuple(dict.fromkeys(credential_paths)) + + +def sanitize_serialized_session_state_mount_credentials( + payload: dict[str, object], +) -> dict[str, object]: + """Strip mount credentials and preserve the trusted-rebind marker.""" + + sanitized_payload, mount_paths = sanitize_serialized_mount_credentials(payload) + marker_present = REDACTED_MOUNT_CREDENTIAL_PATHS_KEY in sanitized_payload + if not mount_paths and not marker_present: + return sanitized_payload + + existing_marker = sanitized_payload.get(REDACTED_MOUNT_CREDENTIAL_PATHS_KEY) + if marker_present and not isinstance(existing_marker, list): + raise ValueError("Persisted sandbox mount credential marker must be a list") + if isinstance(existing_marker, list) and not all( + isinstance(path, str) for path in existing_marker + ): + raise ValueError("Persisted sandbox mount credential marker must contain strings") + existing_paths = cast(list[str], existing_marker) if isinstance(existing_marker, list) else [] + valid_mount_paths = _raw_mount_entry_paths(sanitized_payload) + if any(path not in valid_mount_paths for path in (*existing_paths, *mount_paths)): + raise ValueError("Persisted sandbox mount credential marker contains an invalid path") + sanitized_payload[REDACTED_MOUNT_CREDENTIAL_PATHS_KEY] = list( + dict.fromkeys((*existing_paths, *mount_paths)) + ) + return sanitized_payload + + +def sanitize_run_state_sandbox_mount_credentials( + payload: Mapping[str, object], +) -> dict[str, object]: + """Sanitize every serialized session state retained by a RunState sandbox envelope.""" + + sanitized = copy.deepcopy(dict(payload)) + if set(sanitized) - _RUN_STATE_SANDBOX_FIELDS: + raise ValueError("RunState sandbox payload has an invalid shape") + for field_name in ("backend_id", "current_agent_key", "current_agent_name"): + if field_name in sanitized and not isinstance(sanitized[field_name], str): + raise ValueError(f"RunState sandbox {field_name} must be a string") + if "current_agent_id" in sanitized and type(sanitized["current_agent_id"]) is not int: + raise ValueError("RunState sandbox current_agent_id must be an integer") + if "session_state" in sanitized: + session_state = sanitized["session_state"] + if not isinstance(session_state, dict): + raise ValueError("RunState sandbox session_state must be an object") + sanitized["session_state"] = _sanitize_run_state_session_state(session_state) + + if "sessions_by_agent" not in sanitized: + return sanitized + sessions_by_agent = sanitized["sessions_by_agent"] + if not isinstance(sessions_by_agent, dict): + raise ValueError("RunState sandbox sessions_by_agent must be an object") + for key, entry in sessions_by_agent.items(): + if not isinstance(key, str): + raise ValueError("RunState sandbox session keys must be strings") + if not isinstance(entry, dict): + raise ValueError("RunState sandbox session entries must be objects") + if "session_state" in entry: + if set(entry) - {"agent_name", "session_state"}: + raise ValueError("RunState sandbox session entry has an ambiguous shape") + if "agent_name" in entry and not isinstance(entry["agent_name"], str): + raise ValueError("RunState sandbox session agent_name must be a string") + nested_session_state = entry["session_state"] + if not isinstance(nested_session_state, dict): + raise ValueError("RunState sandbox nested session_state must be an object") + entry["session_state"] = _sanitize_run_state_session_state(nested_session_state) + elif "manifest" in entry: + sessions_by_agent[key] = _sanitize_run_state_session_state(entry) + else: + raise ValueError("RunState sandbox session entry is missing session_state") + return sanitized + + +def _sanitize_run_state_session_state(payload: dict[str, object]) -> dict[str, object]: + from .session.sandbox_session_state import ( + REDACTED_HOST_PATH_GRANT_PATHS_KEY, + SandboxSessionState, + ) + + state_type = payload.get("type") + if not isinstance(state_type, str): + raise ValueError("RunState sandbox session_state must include a string type") + state_class = SandboxSessionState._subclass_registry.get(state_type) + if state_class is None: + raise ValueError("RunState sandbox session_state has an unknown type") + allowed_fields = { + *state_class.model_fields, + REDACTED_HOST_PATH_GRANT_PATHS_KEY, + REDACTED_MOUNT_CREDENTIAL_PATHS_KEY, + } + if set(payload) - allowed_fields: + raise ValueError("RunState sandbox session_state has an invalid shape") + + state = SandboxSessionState.parse(payload) + normalized = cast(dict[str, object], state.model_dump(mode="json")) + if state.path_grants_require_rebind: + normalized[REDACTED_HOST_PATH_GRANT_PATHS_KEY] = list(state.path_grants_require_rebind) + if state.mount_credentials_require_rebind: + normalized[REDACTED_MOUNT_CREDENTIAL_PATHS_KEY] = list( + state.mount_credentials_require_rebind + ) + return normalized + + +def _raw_mount_entry_paths(payload: dict[str, object]) -> frozenset[str]: + manifest = payload.get("manifest") + if not isinstance(manifest, dict): + return frozenset() + entries = manifest.get("entries") + if not isinstance(entries, dict): + return frozenset() + registered_types = BaseEntry.registered_types() + mount_paths: set[str] = set() + stack: list[tuple[str, dict[Any, Any]]] = [("", entries)] + while stack: + prefix, current_entries = stack.pop() + for raw_name, raw_entry in current_entries.items(): + if not isinstance(raw_entry, dict): + continue + name = str(raw_name) + entry_path = f"{prefix}/{name}" if prefix else name + entry_type = raw_entry.get("type") + entry_class = registered_types.get(entry_type) if isinstance(entry_type, str) else None + if entry_class is not None and issubclass(entry_class, Mount): + mount_paths.add(entry_path) + children = raw_entry.get("children") + if isinstance(children, dict): + stack.append((entry_path, children)) + return frozenset(mount_paths) + + +def rebind_manifest_mount_credentials( + manifest: Manifest, + trusted_manifest: Manifest | None, + mount_paths: tuple[str, ...], +) -> Manifest: + """Restore credentials only when persisted and trusted mount topology is identical.""" + + if not mount_paths: + return manifest + if trusted_manifest is None: + raise ValueError( + "Sandbox session state contains mount credentials that require a current trusted " + "manifest before resume" + ) + + rebound = manifest.model_copy(deep=True) + rebound_mounts = _mounts_by_entry_path(rebound) + trusted_mounts = _mounts_by_entry_path(trusted_manifest) + mismatched_topology_paths = sorted(set(rebound_mounts) ^ set(trusted_mounts)) + if mismatched_topology_paths: + raise ValueError( + "Sandbox session state mount topology does not match current trusted configuration " + f"for these paths: {', '.join(mismatched_topology_paths)}" + ) + + mismatched_paths = [ + path + for path in rebound_mounts + if _without_credentials(rebound_mounts[path]) != _without_credentials(trusted_mounts[path]) + ] + if mismatched_paths: + raise ValueError( + "Sandbox session state mount configuration does not match current trusted " + f"configuration for these paths: {', '.join(mismatched_paths)}" + ) + + for path in mount_paths: + rebound_mounts[path]._restore_configured_credentials(trusted_mounts[path]) + rebound._copy_mount_credential_exposure_policy_from(trusted_manifest) + return rebound + + +def _sanitize_entry_mapping( + entries: dict[Any, Any], + *, + prefix: str, + credential_paths: list[str], + entry_paths: frozenset[str], + root: Path, +) -> None: + registered_types = BaseEntry.registered_types() + all_credential_fields = { + field_name + for entry_class in registered_types.values() + if issubclass(entry_class, Mount) + for field_name in entry_class._credential_field_names + } + + for raw_name, raw_entry in entries.items(): + if not isinstance(raw_entry, dict): + raise ValueError("Persisted sandbox manifest entries must contain objects") + name = str(raw_name) + entry_path = f"{prefix}/{name}" if prefix else name + entry_type = raw_entry.get("type") + entry_class = registered_types.get(entry_type) if isinstance(entry_type, str) else None + mount_class: type[Mount] | None = ( + entry_class if entry_class is not None and issubclass(entry_class, Mount) else None + ) + looks_like_mount = ( + mount_class is not None or entry_class is None or "mount_strategy" in raw_entry + ) + removed = False + if looks_like_mount: + credential_fields = ( + mount_class._credential_field_names + if mount_class is not None + else all_credential_fields + ) + for field_name in credential_fields: + if field_name in raw_entry: + value = raw_entry.pop(field_name) + removed = removed or value is not None + + if "mount_strategy" in raw_entry: + strategy = raw_entry["mount_strategy"] + if not isinstance(strategy, dict): + raise ValueError("Persisted sandbox mount_strategy must be an object") + if "pattern" in strategy: + pattern = strategy["pattern"] + if not isinstance(pattern, dict): + raise ValueError("Persisted sandbox mount pattern must be an object") + config_file_path = pattern.get("config_file_path") + nested_pattern = { + key: value for key, value in pattern.items() if key != "config_file_path" + } + if _contains_nested_key(nested_pattern, "config_file_path"): + raise ValueError( + "Persisted sandbox mount pattern contains an invalid credential source" + ) + if isinstance(config_file_path, str) and _references_raw_manifest_entry( + config_file_path, + root=root, + entry_paths=entry_paths, + ): + raise ValueError( + "Persisted rclone credential source must not be a manifest entry" + ) + if "config_file_path" in pattern: + removed = removed or pattern.pop("config_file_path") is not None + + if removed: + credential_paths.append(entry_path) + + children = raw_entry.get("children") + if "children" in raw_entry and not isinstance(children, dict): + raise ValueError("Persisted sandbox entry children must be an object") + if isinstance(children, dict): + _sanitize_entry_mapping( + children, + prefix=entry_path, + credential_paths=credential_paths, + entry_paths=entry_paths, + root=root, + ) + + +def _collect_raw_entry_paths( + entries: dict[Any, Any], + *, + prefix: str = "", +) -> frozenset[str]: + paths: set[str] = set() + for raw_name, raw_entry in entries.items(): + if not isinstance(raw_entry, dict): + raise ValueError("Persisted sandbox manifest entries must contain objects") + name = str(raw_name) + entry_path = f"{prefix}/{name}" if prefix else name + paths.add(entry_path) + children = raw_entry.get("children") + if "children" in raw_entry and not isinstance(children, dict): + raise ValueError("Persisted sandbox entry children must be an object") + if isinstance(children, dict): + paths.update(_collect_raw_entry_paths(children, prefix=entry_path)) + return frozenset(paths) + + +def _references_raw_manifest_entry( + config_file_path: str, + *, + root: Path, + entry_paths: frozenset[str], +) -> bool: + try: + config_path = Path(config_file_path) + resolved_path = config_path if config_path.is_absolute() else root / config_path + normalized_path = Manifest._normalize_in_workspace_path(root, resolved_path) + if normalized_path is None: + return False + return normalized_path.relative_to(root).as_posix() in entry_paths + except (InvalidManifestPathError, TypeError, ValueError): + return False + + +def _contains_nested_key(value: object, key: str) -> bool: + if isinstance(value, dict): + return key in value or any(_contains_nested_key(item, key) for item in value.values()) + if isinstance(value, list): + return any(_contains_nested_key(item, key) for item in value) + return False + + +def _without_credentials(mount: Mount) -> Mount: + redacted = mount.model_copy(deep=True) + redacted._clear_configured_credentials() + return redacted + + +def _reject_serialized_rclone_config_source( + manifest: Manifest, + mount: Mount, + mount_path: Path, +) -> None: + pattern = mount._rclone_pattern() + if pattern is None or pattern.config_file_path is None: + return + + root = posix_path_as_path(coerce_posix_path(manifest.root)) + config_path = pattern.config_file_path + resolved_path = config_path if config_path.is_absolute() else root / config_path + normalized_path = manifest._normalize_in_workspace_path(root, resolved_path) + if normalized_path is None: + return + relative_path = normalized_path.relative_to(root) + if any(entry_path == relative_path for entry_path, _entry in manifest.iter_entries()): + raise MountConfigError( + message=( + "rclone credential config must not be supplied by a serialized manifest entry; " + "provide it through trusted live sandbox configuration" + ), + context={ + "mount_path": mount_path.as_posix(), + "mount_type": mount.type, + "config_path": relative_path.as_posix(), + }, + ) + + +def _mounts_by_entry_path(manifest: Manifest) -> dict[str, Mount]: + mounts: dict[str, Mount] = {} + for entry_path, entry in manifest.iter_entries(): + if not isinstance(entry, Mount): + continue + path = entry_path.as_posix() + if path in mounts: + raise ValueError(f"Manifest contains multiple mounts at {path}") + mounts[path] = entry + return mounts diff --git a/src/agents/sandbox/entries/mounts/base.py b/src/agents/sandbox/entries/mounts/base.py index 9c8bcf1705..5fc1808c64 100644 --- a/src/agents/sandbox/entries/mounts/base.py +++ b/src/agents/sandbox/entries/mounts/base.py @@ -6,7 +6,7 @@ import warnings from collections.abc import Mapping from pathlib import Path -from typing import TYPE_CHECKING, ClassVar, Literal +from typing import TYPE_CHECKING, Any, ClassVar, Literal, cast from pydantic import BaseModel, Field, SerializeAsAny, field_validator @@ -15,12 +15,20 @@ from ...types import FileMode, Permissions from ...workspace_paths import coerce_posix_path, posix_path_as_path, windows_absolute_path from ..base import BaseEntry -from .patterns import MountPattern, MountPatternBase, MountPatternConfig +from .patterns import MountPattern, MountPatternBase, MountPatternConfig, RcloneMountPattern if TYPE_CHECKING: from ...session.base_sandbox_session import BaseSandboxSession +_MountCredentialBoundary = Literal[ + "inside_sandbox", + "outside_sandbox", + "client_opt_in", + "unknown", +] + + class InContainerMountAdapter: """Default adapter for mounts materialized by commands inside the sandbox. @@ -124,6 +132,7 @@ def build_docker_volume_driver_config( class MountStrategyBase(BaseModel, abc.ABC): type: str _subclass_registry: ClassVar[dict[str, builtins.type[MountStrategyBase]]] = {} + credential_boundary: ClassVar[_MountCredentialBoundary] = "unknown" @classmethod def __pydantic_init_subclass__(cls, **kwargs: object) -> None: @@ -223,6 +232,7 @@ def build_docker_volume_driver_config( class InContainerMountStrategy(MountStrategyBase): type: Literal["in_container"] = "in_container" pattern: MountPattern + credential_boundary: ClassVar[_MountCredentialBoundary] = "inside_sandbox" def validate_mount(self, mount: Mount) -> None: mount.in_container_adapter().validate(self) @@ -273,6 +283,7 @@ class DockerVolumeMountStrategy(MountStrategyBase): type: Literal["docker_volume"] = "docker_volume" driver: str driver_options: dict[str, str] = Field(default_factory=dict) + credential_boundary: ClassVar[_MountCredentialBoundary] = "outside_sandbox" def validate_mount(self, mount: Mount) -> None: mount.docker_volume_adapter().validate(self) @@ -351,6 +362,7 @@ class Mount(BaseEntry): ephemeral: bool = True read_only: bool = Field(default=True) mount_strategy: MountStrategy + _credential_field_names: ClassVar[frozenset[str]] = frozenset() @field_validator("mount_strategy", mode="before") @classmethod @@ -409,6 +421,61 @@ def docker_volume_adapter(self) -> DockerVolumeMountAdapter: return DockerVolumeMountAdapter(self) + def _configured_credential_fields(self) -> tuple[str, ...]: + """Return credential-bearing fields configured for this mount. + + Field names are safe diagnostic metadata. Values must never be returned or included in + errors because callers use this method at the sandbox trust boundary. + """ + + configured = [ + field_name + for field_name in sorted(self._credential_field_names) + if getattr(self, field_name, None) is not None + ] + pattern = self._rclone_pattern() + if pattern is not None and pattern.config_file_path is not None: + configured.append("mount_strategy.pattern.config_file_path") + return tuple(configured) + + def _rclone_pattern(self) -> RcloneMountPattern | None: + pattern = getattr(self.mount_strategy, "pattern", None) + return pattern if isinstance(pattern, RcloneMountPattern) else None + + def _clear_configured_credentials(self) -> None: + """Clear credential-bearing configuration on an internal manifest copy.""" + + for field_name in self._credential_field_names: + if hasattr(self, field_name): + setattr(self, field_name, None) + + pattern = self._rclone_pattern() + if pattern is not None: + cast(Any, self.mount_strategy).pattern = pattern.model_copy( + update={"config_file_path": None} + ) + + def _restore_configured_credentials(self, trusted_mount: Mount) -> None: + """Restore credential-bearing fields on an internal manifest copy.""" + + if type(self) is not type(trusted_mount): + raise ValueError("Persisted mount credential rebind requires a matching mount type") + if type(self.mount_strategy) is not type(trusted_mount.mount_strategy): + raise ValueError("Persisted mount credential rebind requires a matching mount strategy") + + for field_name in self._credential_field_names: + if hasattr(self, field_name): + setattr(self, field_name, getattr(trusted_mount, field_name)) + + rebound_pattern = self._rclone_pattern() + trusted_pattern = trusted_mount._rclone_pattern() + if (rebound_pattern is None) != (trusted_pattern is None): + raise ValueError("Persisted mount credential rebind requires a matching mount pattern") + if rebound_pattern is not None and trusted_pattern is not None: + cast(Any, self.mount_strategy).pattern = rebound_pattern.model_copy( + update={"config_file_path": trusted_pattern.config_file_path} + ) + async def apply( self, session: BaseSandboxSession, diff --git a/src/agents/sandbox/entries/mounts/patterns.py b/src/agents/sandbox/entries/mounts/patterns.py index 6aeeea974b..07803752a6 100644 --- a/src/agents/sandbox/entries/mounts/patterns.py +++ b/src/agents/sandbox/entries/mounts/patterns.py @@ -701,6 +701,15 @@ async def read_config_text( context={"type": mount_type or "mount"}, ) config_path = self._resolve_config_path(session, self.config_file_path) + manifest_root = posix_path_as_path( + coerce_posix_path(getattr(session.state.manifest, "root", "/")) + ) + try: + workspace_config_path = config_path.relative_to(manifest_root) + except ValueError: + workspace_config_path = None + if workspace_config_path is not None: + session.register_persist_workspace_skip_path(workspace_config_path) try: handle = await session.read(config_path) except WorkspaceReadNotFoundError: diff --git a/src/agents/sandbox/entries/mounts/providers/azure_blob.py b/src/agents/sandbox/entries/mounts/providers/azure_blob.py index 7623c39958..e5b822f708 100644 --- a/src/agents/sandbox/entries/mounts/providers/azure_blob.py +++ b/src/agents/sandbox/entries/mounts/providers/azure_blob.py @@ -1,7 +1,7 @@ from __future__ import annotations import builtins -from typing import TYPE_CHECKING, Literal +from typing import TYPE_CHECKING, ClassVar, Literal from ....errors import MountConfigError from ..base import DockerVolumeMountStrategy @@ -20,6 +20,7 @@ class AzureBlobMount(_ConfiguredMount): type: Literal["azure_blob_mount"] = "azure_blob_mount" + _credential_field_names: ClassVar[frozenset[str]] = frozenset({"account_key"}) account: str # AZURE_STORAGE_ACCOUNT container: str # AZURE_STORAGE_CONTAINER endpoint: str | None = None diff --git a/src/agents/sandbox/entries/mounts/providers/box.py b/src/agents/sandbox/entries/mounts/providers/box.py index 444129159e..2b4b356908 100644 --- a/src/agents/sandbox/entries/mounts/providers/box.py +++ b/src/agents/sandbox/entries/mounts/providers/box.py @@ -1,7 +1,7 @@ from __future__ import annotations import builtins -from typing import TYPE_CHECKING, Literal +from typing import TYPE_CHECKING, ClassVar, Literal from ....errors import MountConfigError from ..base import DockerVolumeMountStrategy @@ -21,6 +21,15 @@ class BoxMount(_ConfiguredMount): """ type: Literal["box_mount"] = "box_mount" + _credential_field_names: ClassVar[frozenset[str]] = frozenset( + { + "client_secret", + "access_token", + "token", + "box_config_file", + "config_credentials", + } + ) path: str | None = None client_id: str | None = None client_secret: str | None = None diff --git a/src/agents/sandbox/entries/mounts/providers/gcs.py b/src/agents/sandbox/entries/mounts/providers/gcs.py index 8e3838b3bc..688489b081 100644 --- a/src/agents/sandbox/entries/mounts/providers/gcs.py +++ b/src/agents/sandbox/entries/mounts/providers/gcs.py @@ -1,7 +1,7 @@ from __future__ import annotations import builtins -from typing import TYPE_CHECKING, Literal +from typing import TYPE_CHECKING, ClassVar, Literal from ....errors import MountConfigError from ..base import DockerVolumeMountStrategy @@ -20,6 +20,15 @@ class GCSMount(_ConfiguredMount): type: Literal["gcs_mount"] = "gcs_mount" + _credential_field_names: ClassVar[frozenset[str]] = frozenset( + { + "access_id", + "secret_access_key", + "service_account_file", + "service_account_credentials", + "access_token", + } + ) bucket: str access_id: str | None = None secret_access_key: str | None = None diff --git a/src/agents/sandbox/entries/mounts/providers/r2.py b/src/agents/sandbox/entries/mounts/providers/r2.py index 33490eaf29..00cd73009d 100644 --- a/src/agents/sandbox/entries/mounts/providers/r2.py +++ b/src/agents/sandbox/entries/mounts/providers/r2.py @@ -1,7 +1,7 @@ from __future__ import annotations import builtins -from typing import TYPE_CHECKING, Literal +from typing import TYPE_CHECKING, ClassVar, Literal from ....errors import MountConfigError from ..base import DockerVolumeMountStrategy @@ -14,6 +14,9 @@ class R2Mount(_ConfiguredMount): type: Literal["r2_mount"] = "r2_mount" + _credential_field_names: ClassVar[frozenset[str]] = frozenset( + {"access_key_id", "secret_access_key"} + ) bucket: str account_id: str access_key_id: str | None = None diff --git a/src/agents/sandbox/entries/mounts/providers/s3.py b/src/agents/sandbox/entries/mounts/providers/s3.py index e44d95ba2b..78e979a29e 100644 --- a/src/agents/sandbox/entries/mounts/providers/s3.py +++ b/src/agents/sandbox/entries/mounts/providers/s3.py @@ -1,7 +1,7 @@ from __future__ import annotations import builtins -from typing import TYPE_CHECKING, Literal +from typing import TYPE_CHECKING, ClassVar, Literal from ....errors import MountConfigError from ..base import DockerVolumeMountStrategy @@ -20,6 +20,9 @@ class S3Mount(_ConfiguredMount): type: Literal["s3_mount"] = "s3_mount" + _credential_field_names: ClassVar[frozenset[str]] = frozenset( + {"access_key_id", "secret_access_key", "session_token"} + ) bucket: str access_key_id: str | None = None secret_access_key: str | None = None diff --git a/src/agents/sandbox/manifest.py b/src/agents/sandbox/manifest.py index 62d88e202f..a59bbd24ae 100644 --- a/src/agents/sandbox/manifest.py +++ b/src/agents/sandbox/manifest.py @@ -7,6 +7,7 @@ from pydantic import ( BaseModel, Field, + PrivateAttr, SerializeAsAny, field_serializer, field_validator, @@ -225,6 +226,56 @@ class Manifest(BaseModel): remote_mount_command_allowlist: list[str] = Field( default_factory=lambda: list(DEFAULT_REMOTE_MOUNT_COMMAND_ALLOWLIST) ) + _in_container_mount_credential_exposure_allowed_paths: frozenset[str] = PrivateAttr( + default_factory=frozenset + ) + + def with_in_container_mount_credential_exposure_allowed( + self, + *mount_paths: str | PurePath, + ) -> "Manifest": + """Return a trusted manifest that acknowledges credential exposure for mount paths. + + This application-side policy is intentionally private runtime state. It is not accepted + from manifest dictionaries and is never serialized into sandbox or RunState state. + """ + + if not mount_paths: + raise ValueError("At least one in-container mount path must be provided") + + root = posix_path_as_path(coerce_posix_path(self.root)) + allowed = set(self._in_container_mount_credential_exposure_allowed_paths) + for raw_path in mount_paths: + if (windows_path := windows_absolute_path(raw_path)) is not None: + raise InvalidManifestPathError(rel=windows_path.as_posix(), reason="absolute") + path = posix_path_as_path(coerce_posix_path(raw_path)) + if path.is_absolute(): + normalized = self._normalize_in_workspace_path(root, path) + if normalized is None: + raise InvalidManifestPathError(rel=path.as_posix(), reason="absolute") + path = normalized.relative_to(root) + else: + path = resolve_workspace_path(root, path).relative_to(root) + allowed.add(path.as_posix()) + + trusted = self.model_copy(deep=True) + trusted._in_container_mount_credential_exposure_allowed_paths = frozenset(allowed) + return trusted + + def _allows_in_container_mount_credential_exposure(self, mount_path: Path) -> bool: + root = posix_path_as_path(coerce_posix_path(self.root)) + try: + relative_path = mount_path.relative_to(root) + except ValueError: + return False + return ( + relative_path.as_posix() in self._in_container_mount_credential_exposure_allowed_paths + ) + + def _copy_mount_credential_exposure_policy_from(self, trusted_manifest: "Manifest") -> None: + self._in_container_mount_credential_exposure_allowed_paths = frozenset( + trusted_manifest._in_container_mount_credential_exposure_allowed_paths + ) @field_validator("entries", mode="before") @classmethod @@ -390,6 +441,17 @@ def describe(self, depth: int | None = 1) -> str: def _coerce_manifest(value: Manifest | dict[str, Any], *, parameter_name: str) -> Manifest: """Normalize manifest dictionaries without granting untrusted host filesystem access.""" + if isinstance(value, dict) and any( + key in value + for key in ( + "in_container_mount_credential_exposure_allowed_paths", + "_in_container_mount_credential_exposure_allowed_paths", + ) + ): + raise TypeError( + f"{parameter_name} in-container mount credential exposure must be configured " + "on a trusted Manifest instance, not in a dictionary" + ) if isinstance(value, dict) and "extra_path_grants" in value: extra_path_grants = value["extra_path_grants"] if not isinstance(extra_path_grants, list | tuple) or extra_path_grants: diff --git a/src/agents/sandbox/runtime_session_manager.py b/src/agents/sandbox/runtime_session_manager.py index c3a4fda9ad..aebe860c89 100644 --- a/src/agents/sandbox/runtime_session_manager.py +++ b/src/agents/sandbox/runtime_session_manager.py @@ -772,8 +772,27 @@ def _process_resumed_state_manifest( session_state: SandboxSessionState, trusted_manifest: Manifest | None, ) -> SandboxSessionState: - resume_manifest = session_state.manifest - if session_state.path_grants_require_rebind and trusted_manifest is not None: + if session_state.mount_credentials_require_rebind: + trusted_resume_manifest = cls._process_manifest( + capabilities, + trusted_manifest, + run_as_user=cls._agent_run_as_user(agent), + ) + rebound_state = session_state.rebind_persisted_mount_credentials( + trusted_resume_manifest + ) + if trusted_resume_manifest is None: + return rebound_state + trusted_resume_manifest = trusted_resume_manifest.model_copy( + update={"root": rebound_state.manifest.root}, + deep=True, + ) + processed_state = rebound_state.model_copy(update={"manifest": trusted_resume_manifest}) + return processed_state.rebind_persisted_path_grants(trusted_resume_manifest) + + rebound_state = session_state + resume_manifest = rebound_state.manifest + if rebound_state.path_grants_require_rebind and trusted_manifest is not None: resume_manifest = resume_manifest.model_copy( update={ "extra_path_grants": tuple( @@ -787,8 +806,8 @@ def _process_resumed_state_manifest( run_as_user=cls._agent_run_as_user(agent), ) if processed_manifest is None: - return session_state - processed_state = session_state.model_copy(update={"manifest": processed_manifest}) + return rebound_state + processed_state = rebound_state.model_copy(update={"manifest": processed_manifest}) return processed_state.rebind_persisted_path_grants(processed_manifest) @staticmethod diff --git a/src/agents/sandbox/sandboxes/docker.py b/src/agents/sandbox/sandboxes/docker.py index ac7100399b..92031b1aad 100644 --- a/src/agents/sandbox/sandboxes/docker.py +++ b/src/agents/sandbox/sandboxes/docker.py @@ -1470,6 +1470,7 @@ async def create( image = options.image session_id = uuid.uuid4() manifest = manifest or Manifest() + self._validate_manifest_mount_credentials(manifest) _validate_docker_path_grants(manifest) container = await self._create_container( @@ -1537,7 +1538,7 @@ async def resume( ) -> SandboxSession: if not isinstance(state, DockerSandboxSessionState): raise TypeError("DockerSandboxClient.resume expects a DockerSandboxSessionState") - state.assert_path_grants_rebound() + state.assert_trusted_manifest_rebound() _validate_docker_path_grants(state.manifest) container = self.get_container(state.container_id) reused_existing_container = container is not None diff --git a/src/agents/sandbox/sandboxes/unix_local.py b/src/agents/sandbox/sandboxes/unix_local.py index 615987da5a..536d28be5b 100644 --- a/src/agents/sandbox/sandboxes/unix_local.py +++ b/src/agents/sandbox/sandboxes/unix_local.py @@ -1102,6 +1102,7 @@ async def create( ) -> SandboxSession: resolved_options = options or UnixLocalSandboxClientOptions() if manifest is not None: + self._validate_manifest_mount_credentials(manifest) _assert_unix_local_host_path_grants_unsupported(manifest) # For local execution, runner-created sessions should always get an isolated temp root # unless the caller explicitly chose a custom host path. @@ -1162,7 +1163,7 @@ async def resume( ) -> SandboxSession: if not isinstance(state, UnixLocalSandboxSessionState): raise TypeError("UnixLocalSandboxClient.resume expects a UnixLocalSandboxSessionState") - state.assert_path_grants_rebound() + state.assert_trusted_manifest_rebound() _assert_unix_local_host_path_grants_unsupported(state.manifest) inner = UnixLocalSandboxSession.from_state(state) return self._wrap_session(inner, instrumentation=self._instrumentation) diff --git a/src/agents/sandbox/session/manifest_application.py b/src/agents/sandbox/session/manifest_application.py index bb3569a9fa..67eaecb009 100644 --- a/src/agents/sandbox/session/manifest_application.py +++ b/src/agents/sandbox/session/manifest_application.py @@ -4,6 +4,7 @@ from pathlib import Path from ...run_config import DEFAULT_MAX_MANIFEST_ENTRY_CONCURRENCY +from .._mount_security import validate_mount_credential_boundaries from ..entries import BaseEntry, Dir, Mount, resolve_workspace_path from ..manifest import Manifest from ..materialization import MaterializationResult, MaterializedFile, gather_in_order @@ -35,6 +36,7 @@ async def apply_manifest( provision_accounts: bool = True, base_dir: Path | None = None, ) -> MaterializationResult: + validate_mount_credential_boundaries(manifest) base_dir = posix_path_as_path(coerce_posix_path("/")) if base_dir is None else base_dir root = posix_path_as_path(coerce_posix_path(manifest.root)) diff --git a/src/agents/sandbox/session/sandbox_client.py b/src/agents/sandbox/session/sandbox_client.py index fe92fae55e..07fd581d68 100644 --- a/src/agents/sandbox/session/sandbox_client.py +++ b/src/agents/sandbox/session/sandbox_client.py @@ -5,6 +5,11 @@ from pydantic import BaseModel, ConfigDict, model_serializer +from .._mount_security import ( + REDACTED_MOUNT_CREDENTIAL_PATHS_KEY, + redact_manifest_mount_credentials, + validate_mount_credential_boundaries, +) from ..manifest import Manifest from ..snapshot import SnapshotBase, SnapshotSpec from .base_sandbox_session import BaseSandboxSession @@ -105,6 +110,11 @@ class BaseSandboxClient(abc.ABC, Generic[ClientOptionsT]): supports_default_options: bool = False _dependencies: Dependencies | None = None + @staticmethod + def _validate_manifest_mount_credentials(manifest: Manifest | None) -> None: + if manifest is not None: + validate_mount_credential_boundaries(manifest) + def _resolve_dependencies(self) -> Dependencies | None: if self._dependencies is None: return None @@ -175,7 +185,9 @@ async def resume( def serialize_session_state(self, state: SandboxSessionState) -> dict[str, object]: """Serialize backend-specific sandbox state into a JSON-compatible payload.""" + validate_mount_credential_boundaries(state.manifest) redacted_paths = set(state.path_grants_require_rebind) + redacted_mount_paths = set(state.mount_credentials_require_rebind) persistent_grants = [] for grant in state.manifest.extra_path_grants: if grant.host_path is not None: @@ -186,10 +198,14 @@ def serialize_session_state(self, state: SandboxSessionState) -> dict[str, objec persistent_manifest = state.manifest.model_copy( update={"extra_path_grants": tuple(persistent_grants)}, ) + persistent_manifest, mount_paths = redact_manifest_mount_credentials(persistent_manifest) + redacted_mount_paths.update(mount_paths) persistent_state = state.model_copy(update={"manifest": persistent_manifest}) payload = cast(dict[str, object], persistent_state.model_dump(mode="json")) if redacted_paths: payload[REDACTED_HOST_PATH_GRANT_PATHS_KEY] = sorted(redacted_paths) + if redacted_mount_paths: + payload[REDACTED_MOUNT_CREDENTIAL_PATHS_KEY] = sorted(redacted_mount_paths) return payload @staticmethod @@ -197,8 +213,14 @@ def _deserialize_session_state_payload( payload: dict[str, object], state_class: type[SandboxSessionState], ) -> SandboxSessionState: - state = state_class.model_validate(payload) - return SandboxSessionState._mark_persisted_path_grants(state, payload=payload) + sanitized_payload = SandboxSessionState._sanitize_persisted_mount_credentials_payload( + payload + ) + state = state_class.model_validate(sanitized_payload) + return SandboxSessionState._mark_persisted_path_grants( + state, + payload=sanitized_payload, + ) @abc.abstractmethod def deserialize_session_state(self, payload: dict[str, object]) -> SandboxSessionState: diff --git a/src/agents/sandbox/session/sandbox_session_state.py b/src/agents/sandbox/session/sandbox_session_state.py index f5f38583e1..005ddc0efe 100644 --- a/src/agents/sandbox/session/sandbox_session_state.py +++ b/src/agents/sandbox/session/sandbox_session_state.py @@ -14,6 +14,13 @@ model_serializer, ) +from .._mount_security import ( + REDACTED_MOUNT_CREDENTIAL_PATHS_KEY, + rebind_manifest_mount_credentials, + redact_manifest_mount_credentials, + sanitize_serialized_session_state_mount_credentials, + validate_mount_credential_boundaries, +) from ..manifest import Manifest from ..snapshot import SnapshotBase @@ -34,11 +41,16 @@ class SandboxSessionState(BaseModel): _subclass_registry: ClassVar[dict[str, SessionStateClass]] = {} _path_grants_require_rebind: tuple[str, ...] = PrivateAttr(default=()) + _mount_credentials_require_rebind: tuple[str, ...] = PrivateAttr(default=()) @property def path_grants_require_rebind(self) -> tuple[str, ...]: return self._path_grants_require_rebind + @property + def mount_credentials_require_rebind(self) -> tuple[str, ...]: + return self._mount_credentials_require_rebind + @classmethod def __pydantic_init_subclass__(cls, **kwargs: Any) -> None: """Auto-register every subclass by its ``type`` field default.""" @@ -76,7 +88,8 @@ def parse(cls, payload: object) -> SandboxSessionState: payload = payload.model_dump() if isinstance(payload, dict): - state_type = payload.get("type") + sanitized_payload = cls._sanitize_persisted_mount_credentials_payload(payload) + state_type = sanitized_payload.get("type") if not isinstance(state_type, str): raise ValueError("sandbox session state payload must include a string `type`") @@ -85,12 +98,19 @@ def parse(cls, payload: object) -> SandboxSessionState: raise ValueError(f"unknown sandbox session state type `{state_type}`") return cls._mark_persisted_path_grants( - subclass.model_validate(payload), - payload=payload, + subclass.model_validate(sanitized_payload), + payload=sanitized_payload, ) raise TypeError("session state payload must be a SandboxSessionState or dict") + @classmethod + def _sanitize_persisted_mount_credentials_payload( + cls, + payload: dict[str, object], + ) -> dict[str, object]: + return sanitize_serialized_session_state_mount_credentials(payload) + @classmethod def _mark_persisted_path_grants( cls, @@ -113,6 +133,15 @@ def _mark_persisted_path_grants( sanitized_manifest = state.manifest.model_copy( update={"extra_path_grants": persistent_grants}, ) + sanitized_manifest, serialized_mount_credential_paths = redact_manifest_mount_credentials( + sanitized_manifest + ) + redacted_mount_value = payload.get(REDACTED_MOUNT_CREDENTIAL_PATHS_KEY) + mount_marker_paths = ( + tuple(path for path in redacted_mount_value if isinstance(path, str)) + if isinstance(redacted_mount_value, list | tuple) + else () + ) marked = state.model_copy(update={"manifest": sanitized_manifest}) marked._path_grants_require_rebind = tuple( dict.fromkeys( @@ -123,6 +152,15 @@ def _mark_persisted_path_grants( ) ) ) + marked._mount_credentials_require_rebind = tuple( + dict.fromkeys( + ( + *state.mount_credentials_require_rebind, + *mount_marker_paths, + *serialized_mount_credential_paths, + ) + ) + ) return marked def rebind_persisted_path_grants( @@ -174,6 +212,34 @@ def assert_path_grants_rebound(self) -> None: "before resume; resume through Runner with SandboxRunConfig.manifest" ) + def rebind_persisted_mount_credentials( + self, + trusted_manifest: Manifest | None, + ) -> SandboxSessionState: + """Restore mount credentials from current trusted application configuration.""" + + if not self.mount_credentials_require_rebind: + return self + rebound_manifest = rebind_manifest_mount_credentials( + self.manifest, + trusted_manifest, + self.mount_credentials_require_rebind, + ) + rebound = self.model_copy(update={"manifest": rebound_manifest}) + rebound._mount_credentials_require_rebind = () + return rebound + + def assert_trusted_manifest_rebound(self) -> None: + """Reject resume until redacted trusted values are rebound and mounts are safe.""" + + self.assert_path_grants_rebound() + if self.mount_credentials_require_rebind: + raise ValueError( + "Sandbox session state mount credentials must be rebound from a current trusted " + "manifest before resume; resume through Runner with SandboxRunConfig.manifest" + ) + validate_mount_credential_boundaries(self.manifest) + @model_serializer(mode="wrap") def _serialize_always_include_defaults(self, handler: Any) -> dict[str, Any]: data: dict[str, Any] = handler(self) diff --git a/tests/extensions/sandbox/test_blaxel.py b/tests/extensions/sandbox/test_blaxel.py index 99efdeb779..e3f7f3e469 100644 --- a/tests/extensions/sandbox/test_blaxel.py +++ b/tests/extensions/sandbox/test_blaxel.py @@ -2832,6 +2832,9 @@ def __init__(self) -> None: self.exec_calls: list[tuple[tuple[str, ...], dict[str, float]]] = [] self._next_results: list[_FakeExecResultForMount] = [] self._default_result = _FakeExecResultForMount() + self.state = type("State", (), {"session_id": uuid.uuid4()})() + self.writes: dict[Path, bytes] = {} + self.persist_skip_paths: list[Path] = [] async def exec(self, *cmd: str, timeout: float = 120) -> _FakeExecResultForMount: self.exec_calls.append((cmd, {"timeout": timeout})) @@ -2839,6 +2842,21 @@ async def exec(self, *cmd: str, timeout: float = 120) -> _FakeExecResultForMount return self._next_results.pop(0) return self._default_result + def register_persist_workspace_skip_path(self, path: Path) -> None: + self.persist_skip_paths.append(path) + + async def mkdir(self, path: Path, *, parents: bool = False) -> None: + _ = (path, parents) + + async def write(self, path: Path, data: io.IOBase) -> None: + self.writes[path] = data.read() + + def normalize_path(self, path: Path) -> Path: + return Path("/workspace") / path + + async def _exec_checked_nonzero(self, *cmd: str) -> _FakeExecResultForMount: + return await self.exec(*cmd) + class __class__: __name__ = "BlaxelSandboxSession" diff --git a/tests/extensions/sandbox/test_blaxel_mounts.py b/tests/extensions/sandbox/test_blaxel_mounts.py index 11d8cd05c5..1c84ef0fac 100644 --- a/tests/extensions/sandbox/test_blaxel_mounts.py +++ b/tests/extensions/sandbox/test_blaxel_mounts.py @@ -1,27 +1,71 @@ from __future__ import annotations +import io import shlex +import uuid +from pathlib import Path from types import SimpleNamespace from typing import Any +import pytest + from agents.extensions.sandbox.blaxel.mounts import ( BlaxelCloudBucketMountConfig, + BlaxelCloudBucketMountStrategy, + BlaxelDriveMountStrategy, _mount_gcs, _mount_s3, ) +from agents.sandbox.errors import MountConfigError _INJECTION = "x; touch /tmp/pwned" +def test_blaxel_mount_strategies_declare_credential_boundaries() -> None: + assert BlaxelCloudBucketMountStrategy.credential_boundary == "inside_sandbox" + assert BlaxelDriveMountStrategy.credential_boundary == "outside_sandbox" + + +def test_sensitive_value_redaction_handles_overlapping_values() -> None: + from agents.extensions.sandbox.blaxel.mounts import _redact_sensitive_values + + assert _redact_sensitive_values("abcdef", ["abc", "abcdef"]) == "REDACTED" + + class _RecordingSession: """Minimal sandbox session that records the `sh -c` commands it is asked to run.""" def __init__(self) -> None: self.commands: list[str] = [] + self.state = SimpleNamespace(session_id=uuid.uuid4()) + self.writes: dict[str, bytes] = {} + self.persist_skip_paths: list[Path] = [] + self.mount_failure: bytes | None = None + self.checked_failure: str | None = None async def exec(self, *args: Any, **kwargs: Any) -> Any: if len(args) >= 3 and args[0] == "sh" and args[1] == "-c": self.commands.append(args[2]) + if self.mount_failure is not None and args[2].startswith(("s3fs", "gcsfuse")): + return SimpleNamespace(exit_code=1, stdout=b"", stderr=self.mount_failure) + return SimpleNamespace(exit_code=0, stdout=b"", stderr=b"") + + def register_persist_workspace_skip_path(self, path: Path) -> None: + self.persist_skip_paths.append(path) + + async def mkdir(self, path: Path, *, parents: bool = False) -> None: + _ = (path, parents) + + async def write(self, path: Path, data: io.IOBase) -> None: + self.writes[path.as_posix()] = data.read() + + def normalize_path(self, path: Path) -> Path: + return Path("/workspace") / path + + async def _exec_checked_nonzero(self, *args: str) -> Any: + self.commands.append(" ".join(args)) + if self.checked_failure == args[0]: + raise RuntimeError(f"{args[0]} failed") return SimpleNamespace(exit_code=0, stdout=b"", stderr=b"") @@ -54,3 +98,66 @@ async def test_gcs_mount_prefix_is_shell_quoted() -> None: ) cmd = next(c for c in session.commands if c.startswith("gcsfuse")) assert "touch" not in shlex.split(cmd) + + +@pytest.mark.parametrize("provider", ["s3", "gcs"]) +async def test_mount_failure_does_not_attach_credentials_to_commands_or_errors( + provider: str, +) -> None: + session = _RecordingSession() + secret = f"{provider}-credential-sentinel" + session.mount_failure = f"provider error included {secret}".encode() + config = BlaxelCloudBucketMountConfig( + provider=provider, # type: ignore[arg-type] + bucket="bucket", + mount_path="/mnt/data", + access_key_id="access-key-sentinel" if provider == "s3" else None, + secret_access_key=secret if provider == "s3" else None, + service_account_key=secret if provider == "gcs" else None, + ) + + with pytest.raises(MountConfigError) as exc_info: + if provider == "s3": + await _mount_s3(session, config) # type: ignore[arg-type] + else: + await _mount_gcs(session, config) # type: ignore[arg-type] + + assert secret not in " ".join(session.commands) + assert secret not in repr(exc_info.value.context) + assert exc_info.value.context["stderr"] == "provider error included REDACTED" + assert session.persist_skip_paths == [Path(".sandbox-blaxel-mount-credentials")] + + +async def test_credential_writer_removes_partial_file_when_chmod_fails() -> None: + from agents.extensions.sandbox.blaxel.mounts import _write_mount_credential_file + + session = _RecordingSession() + session.checked_failure = "chmod" + secret = "credential-sentinel" + + with pytest.raises(RuntimeError, match="chmod failed"): + await _write_mount_credential_file( + session, # type: ignore[arg-type] + name="credential-file", + content=secret, + ) + + assert any(command.startswith("rm -f ") for command in session.commands) + assert secret not in " ".join(session.commands) + + +async def test_mount_fails_when_credential_file_cleanup_is_unconfirmed() -> None: + session = _RecordingSession() + session.checked_failure = "rm" + + with pytest.raises(RuntimeError, match="rm failed"): + await _mount_s3( + session, # type: ignore[arg-type] + BlaxelCloudBucketMountConfig( + provider="s3", + bucket="bucket", + mount_path="/mnt/data", + access_key_id="access", + secret_access_key="secret", + ), + ) diff --git a/tests/extensions/sandbox/test_cloudflare.py b/tests/extensions/sandbox/test_cloudflare.py index 3f707ba3c9..a045ddae35 100644 --- a/tests/extensions/sandbox/test_cloudflare.py +++ b/tests/extensions/sandbox/test_cloudflare.py @@ -321,6 +321,7 @@ def test_cloudflare_bucket_mount_strategy_round_trips_through_manifest_parse() - assert isinstance(mount, S3Mount) assert isinstance(mount.mount_strategy, CloudflareBucketMountStrategy) + assert mount.mount_strategy.credential_boundary == "outside_sandbox" def test_cloudflare_bucket_mount_strategy_builds_s3_config() -> None: diff --git a/tests/extensions/sandbox/test_daytona.py b/tests/extensions/sandbox/test_daytona.py index 02dcb3254d..a5420f6288 100644 --- a/tests/extensions/sandbox/test_daytona.py +++ b/tests/extensions/sandbox/test_daytona.py @@ -1622,6 +1622,10 @@ async def get_command(*_args: object) -> object: # --------------------------------------------------------------------------- +def test_daytona_mount_credentials_cross_the_sandbox_boundary() -> None: + assert DaytonaCloudBucketMountStrategy.credential_boundary == "inside_sandbox" + + class _FakePreflightSession(BaseSandboxSession): """Fake session for testing mount preflights with queued exec results.""" diff --git a/tests/extensions/sandbox/test_e2b.py b/tests/extensions/sandbox/test_e2b.py index f830546517..80c25d8d01 100644 --- a/tests/extensions/sandbox/test_e2b.py +++ b/tests/extensions/sandbox/test_e2b.py @@ -85,6 +85,7 @@ def test_e2b_mount_strategy_type_and_default_pattern() -> None: strategy = E2BCloudBucketMountStrategy() assert strategy.type == "e2b_cloud_bucket" + assert strategy.credential_boundary == "inside_sandbox" assert isinstance(strategy.pattern, RcloneMountPattern) assert strategy.pattern.mode == "fuse" diff --git a/tests/extensions/sandbox/test_modal.py b/tests/extensions/sandbox/test_modal.py index a63582319c..047b6ce61b 100644 --- a/tests/extensions/sandbox/test_modal.py +++ b/tests/extensions/sandbox/test_modal.py @@ -384,6 +384,7 @@ def test_modal_package_re_exports_backend_symbols(monkeypatch: pytest.MonkeyPatc assert ( package_module.ModalCloudBucketMountStrategy is modal_module.ModalCloudBucketMountStrategy ) + assert modal_module.ModalCloudBucketMountStrategy.credential_boundary == "outside_sandbox" @pytest.mark.asyncio diff --git a/tests/extensions/sandbox/test_runloop_mounts.py b/tests/extensions/sandbox/test_runloop_mounts.py index 8ee619fda9..7dbfc0d00a 100644 --- a/tests/extensions/sandbox/test_runloop_mounts.py +++ b/tests/extensions/sandbox/test_runloop_mounts.py @@ -92,6 +92,7 @@ def test_runloop_mount_strategy_type_and_default_pattern() -> None: strategy = RunloopCloudBucketMountStrategy() assert strategy.type == "runloop_cloud_bucket" + assert strategy.credential_boundary == "inside_sandbox" assert isinstance(strategy.pattern, RcloneMountPattern) assert strategy.pattern.mode == "fuse" diff --git a/tests/extensions/sandbox/test_vercel.py b/tests/extensions/sandbox/test_vercel.py index 0cac7d9969..409f15db86 100644 --- a/tests/extensions/sandbox/test_vercel.py +++ b/tests/extensions/sandbox/test_vercel.py @@ -497,6 +497,7 @@ def test_vercel_package_re_exports_backend_symbols(monkeypatch: pytest.MonkeyPat assert package_module.VercelCloudBucketMountStrategy.__name__ == ( "VercelCloudBucketMountStrategy" ) + assert package_module.VercelCloudBucketMountStrategy.credential_boundary == "client_opt_in" assert package_module.VercelSandboxClient is vercel_module.VercelSandboxClient assert package_module.VercelSandboxSessionState is vercel_module.VercelSandboxSessionState diff --git a/tests/sandbox/integration_tests/_helpers.py b/tests/sandbox/integration_tests/_helpers.py index 5eae1c43b2..2f411dd2b6 100644 --- a/tests/sandbox/integration_tests/_helpers.py +++ b/tests/sandbox/integration_tests/_helpers.py @@ -174,7 +174,7 @@ def create_local_sources(tmp_path: Path) -> Path: def build_manifest_with_all_entry_types(*, workspace_root: Path, source_root: Path) -> Manifest: - return Manifest( + manifest = Manifest( root=str(workspace_root), extra_path_grants=(SandboxPathGrant(path=str(source_root)),), entries={ @@ -225,6 +225,12 @@ def build_manifest_with_all_entry_types(*, workspace_root: Path, source_root: Pa ), }, ) + return manifest.with_in_container_mount_credential_exposure_allowed( + "mounts/s3", + "mounts/gcs", + "mounts/r2", + "mounts/azure", + ) def manifest_entry_types(manifest: Manifest) -> set[str]: diff --git a/tests/sandbox/test_mount_security.py b/tests/sandbox/test_mount_security.py new file mode 100644 index 0000000000..2b44d698b8 --- /dev/null +++ b/tests/sandbox/test_mount_security.py @@ -0,0 +1,270 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import pytest + +from agents.extensions.sandbox.daytona import DaytonaCloudBucketMountStrategy +from agents.extensions.sandbox.e2b import E2BCloudBucketMountStrategy +from agents.extensions.sandbox.runloop import RunloopCloudBucketMountStrategy +from agents.sandbox._mount_security import validate_mount_credential_boundaries +from agents.sandbox.entries import ( + AzureBlobMount, + BoxMount, + DockerVolumeMountStrategy, + File, + GCSMount, + InContainerMountStrategy, + R2Mount, + RcloneMountPattern, + S3Mount, +) +from agents.sandbox.errors import MountConfigError +from agents.sandbox.manifest import Manifest, _coerce_manifest +from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxSessionState +from agents.sandbox.session import SandboxSessionState +from agents.sandbox.session.manifest_application import ManifestApplier +from agents.sandbox.snapshot import NoopSnapshot + + +def _s3_manifest(*, strategy: Any, credentials: bool = True) -> Manifest: + return Manifest( + entries={ + "remote": S3Mount( + bucket="example", + access_key_id="access-key" if credentials else None, + secret_access_key="secret-key" if credentials else None, + mount_strategy=strategy, + ) + } + ) + + +def test_credentialed_in_container_mount_is_rejected_without_secret_values() -> None: + manifest = _s3_manifest(strategy=InContainerMountStrategy(pattern=RcloneMountPattern())) + + with pytest.raises(MountConfigError) as exc_info: + validate_mount_credential_boundaries(manifest) + + error = exc_info.value + rendered = f"{error.message} {error.context}" + assert error.context == { + "mount_path": "/workspace/remote", + "mount_type": "s3_mount", + "mount_strategy": "in_container", + "credential_fields": ["access_key_id", "secret_access_key"], + } + assert "access-key" not in rendered + assert "secret-key" not in rendered + + +@pytest.mark.parametrize( + ("mount", "credential_fields"), + [ + ( + S3Mount( + bucket="example", + access_key_id="access", + secret_access_key="secret", + session_token="token", + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ), + ("access_key_id", "secret_access_key", "session_token"), + ), + ( + R2Mount( + bucket="example", + account_id="account", + access_key_id="access", + secret_access_key="secret", + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ), + ("access_key_id", "secret_access_key"), + ), + ( + GCSMount( + bucket="example", + service_account_credentials="service-account", + access_token="token", + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ), + ("access_token", "service_account_credentials"), + ), + ( + AzureBlobMount( + account="account", + container="container", + account_key="key", + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ), + ("account_key",), + ), + ( + BoxMount( + client_secret="client-secret", + token="token", + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ), + ("client_secret", "token"), + ), + ( + S3Mount( + bucket="example", + mount_strategy=InContainerMountStrategy( + pattern=RcloneMountPattern(config_file_path=Path("rclone.conf")) + ), + ), + ("mount_strategy.pattern.config_file_path",), + ), + ], +) +def test_provider_credential_sources_are_classified( + mount: Any, + credential_fields: tuple[str, ...], +) -> None: + assert mount._configured_credential_fields() == credential_fields + + +@pytest.mark.parametrize( + "strategy", + [ + DaytonaCloudBucketMountStrategy( + pattern=RcloneMountPattern(config_file_path=Path("rclone.conf")) + ), + E2BCloudBucketMountStrategy( + pattern=RcloneMountPattern(config_file_path=Path("rclone.conf")) + ), + RunloopCloudBucketMountStrategy( + pattern=RcloneMountPattern(config_file_path=Path("rclone.conf")) + ), + ], +) +def test_hosted_rclone_config_paths_are_rejected_and_redacted(strategy: Any) -> None: + from agents.sandbox._mount_security import ( + rebind_manifest_mount_credentials, + redact_manifest_mount_credentials, + ) + + manifest = _s3_manifest(strategy=strategy, credentials=False) + mount = manifest.entries["remote"] + assert isinstance(mount, S3Mount) + assert mount._configured_credential_fields() == ("mount_strategy.pattern.config_file_path",) + with pytest.raises(MountConfigError): + validate_mount_credential_boundaries(manifest) + + trusted = manifest.with_in_container_mount_credential_exposure_allowed("remote") + redacted, paths = redact_manifest_mount_credentials(trusted) + redacted_mount = redacted.entries["remote"] + assert isinstance(redacted_mount, S3Mount) + redacted_pattern = getattr(redacted_mount.mount_strategy, "pattern", None) + assert isinstance(redacted_pattern, RcloneMountPattern) + assert redacted_pattern.config_file_path is None + rebound = rebind_manifest_mount_credentials(redacted, trusted, paths) + rebound_mount = rebound.entries["remote"] + assert isinstance(rebound_mount, S3Mount) + rebound_pattern = getattr(rebound_mount.mount_strategy, "pattern", None) + assert isinstance(rebound_pattern, RcloneMountPattern) + assert rebound_pattern.config_file_path == Path("rclone.conf") + + +def test_manifest_entry_cannot_supply_rclone_credential_config() -> None: + manifest = Manifest( + entries={ + "rclone.conf": File(content=b"[remote]\nsecret = RCLONE_SECRET_SENTINEL\n"), + "remote": S3Mount( + bucket="example", + mount_strategy=InContainerMountStrategy( + pattern=RcloneMountPattern(config_file_path=Path("rclone.conf")) + ), + ), + } + ).with_in_container_mount_credential_exposure_allowed("remote") + + with pytest.raises(MountConfigError, match="serialized manifest entry") as exc_info: + validate_mount_credential_boundaries(manifest) + + assert "RCLONE_SECRET_SENTINEL" not in repr(exc_info.value) + + +def test_credentialless_in_container_mount_remains_supported() -> None: + manifest = _s3_manifest( + strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + credentials=False, + ) + + validate_mount_credential_boundaries(manifest) + + +def test_docker_volume_mount_keeps_credentials_outside_sandbox() -> None: + manifest = _s3_manifest(strategy=DockerVolumeMountStrategy(driver="rclone")) + + validate_mount_credential_boundaries(manifest) + + +def test_trusted_manifest_can_acknowledge_one_in_container_mount() -> None: + manifest = _s3_manifest(strategy=InContainerMountStrategy(pattern=RcloneMountPattern())) + trusted = manifest.with_in_container_mount_credential_exposure_allowed("remote") + + validate_mount_credential_boundaries(trusted) + + with pytest.raises(MountConfigError): + validate_mount_credential_boundaries(manifest) + assert "in_container_mount_credential_exposure_allowed_paths" not in trusted.model_dump() + + +def test_manifest_dict_cannot_enable_credential_exposure() -> None: + with pytest.raises(TypeError, match="trusted Manifest instance"): + _coerce_manifest( + {"in_container_mount_credential_exposure_allowed_paths": ["remote"]}, + parameter_name="manifest", + ) + + +@pytest.mark.parametrize( + "policy_key", + [ + "in_container_mount_credential_exposure_allowed_paths", + "_in_container_mount_credential_exposure_allowed_paths", + ], +) +def test_persisted_manifest_cannot_enable_credential_exposure(policy_key: str) -> None: + payload = UnixLocalSandboxSessionState( + manifest=Manifest(), + snapshot=NoopSnapshot(id="snapshot"), + ).model_dump(mode="json") + manifest = payload["manifest"] + assert isinstance(manifest, dict) + manifest[policy_key] = ["PERSISTED_POLICY_SECRET_SENTINEL"] + + with pytest.raises(ValueError, match="cannot configure mount credential exposure") as exc_info: + SandboxSessionState.parse(payload) + + assert "PERSISTED_POLICY_SECRET_SENTINEL" not in repr(exc_info.value) + + +@pytest.mark.asyncio +async def test_manifest_application_rejects_before_side_effects() -> None: + calls: list[str] = [] + + async def mkdir(path: Path) -> None: + calls.append(f"mkdir:{path}") + + async def exec_checked_nonzero(*args: object) -> Any: + calls.append(f"exec:{args}") + + async def apply_entry(*args: object) -> list[Any]: + calls.append(f"apply:{args}") + return [] + + applier = ManifestApplier( + mkdir=mkdir, + exec_checked_nonzero=exec_checked_nonzero, + apply_entry=apply_entry, + ) + manifest = _s3_manifest(strategy=InContainerMountStrategy(pattern=RcloneMountPattern())) + + with pytest.raises(MountConfigError): + await applier.apply_manifest(manifest) + + assert calls == [] diff --git a/tests/sandbox/test_mounts.py b/tests/sandbox/test_mounts.py index c65d118fd4..96cf316dcf 100644 --- a/tests/sandbox/test_mounts.py +++ b/tests/sandbox/test_mounts.py @@ -53,6 +53,9 @@ async def read(self, path: Path, *, user: object = None) -> io.BytesIO: _ = (path, user) return io.BytesIO(self._config_text.encode("utf-8")) + def persist_workspace_skip_paths(self) -> set[Path]: + return self._persist_workspace_skip_relpaths() + async def shutdown(self) -> None: return None @@ -300,6 +303,7 @@ async def test_azure_blob_mount_builds_rclone_runtime_config_without_hidden_patt ) assert isinstance(apply_config, RcloneMountConfig) + assert session.persist_workspace_skip_paths() == {Path("rclone.conf")} assert apply_config.remote_name == remote_name assert apply_config.remote_path == "container" assert apply_config.config_text is not None diff --git a/tests/sandbox/test_runtime.py b/tests/sandbox/test_runtime.py index 59a64ff643..9aaa48308e 100644 --- a/tests/sandbox/test_runtime.py +++ b/tests/sandbox/test_runtime.py @@ -53,9 +53,11 @@ ) from agents.sandbox.entries import ( BaseEntry, + DockerVolumeMountStrategy, File, InContainerMountStrategy, MountpointMountPattern, + RcloneMountPattern, S3Mount, ) from agents.sandbox.errors import ( @@ -753,6 +755,23 @@ def process_manifest(self, manifest: Manifest) -> Manifest: return manifest +class _ManifestMountCapability(Capability): + type: str = "manifest-mount" + mount: S3Mount + process_calls: int + + def __init__(self, mount: S3Mount) -> None: + super().__init__( + type="manifest-mount", + **cast(Any, {"mount": mount, "process_calls": 0}), + ) + + def process_manifest(self, manifest: Manifest) -> Manifest: + self.process_calls += 1 + manifest.entries["remote"] = self.mount.model_copy(deep=True) + return manifest + + class _ManifestUsersCapability(Capability): type: str = "manifest-users" @@ -3397,6 +3416,116 @@ async def test_session_manager_rebinds_capability_host_path_grant_once( assert client.resume_state.path_grants_require_rebind == () +def test_session_manager_rebinds_mount_credentials_from_current_trusted_manifest() -> None: + trusted_manifest = Manifest( + entries={ + "remote": S3Mount( + bucket="example", + access_key_id="access-key-sentinel", + secret_access_key="secret-key-sentinel", + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ) + } + ).with_in_container_mount_credential_exposure_allowed("remote") + client = _FakeClient(_FakeSession(Manifest())) + payload = client.serialize_session_state( + TestSessionState( + manifest=trusted_manifest, + snapshot=NoopSnapshot(id="resume"), + ) + ) + state = client._deserialize_session_state_payload(payload, TestSessionState) + agent = SandboxAgent(name="worker", model=FakeModel(), instructions="Worker.") + + processed = SandboxRuntimeSessionManager._process_resumed_state_manifest( + agent=agent, + capabilities=[], + session_state=state, + trusted_manifest=trusted_manifest, + ) + + mount = processed.manifest.entries["remote"] + assert isinstance(mount, S3Mount) + assert mount.access_key_id == "access-key-sentinel" + assert mount.secret_access_key == "secret-key-sentinel" + assert processed.mount_credentials_require_rebind == () + processed.assert_trusted_manifest_rebound() + + +def test_session_manager_rebinds_capability_added_mount_from_current_configuration() -> None: + base_manifest = Manifest() + capability = _ManifestMountCapability( + S3Mount( + bucket="trusted-bucket", + endpoint_url="https://trusted.example.com", + access_key_id="trusted-access", + secret_access_key="trusted-secret", + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ) + ) + fresh_manifest = SandboxRuntimeSessionManager._process_manifest( + [capability], + base_manifest, + ) + assert fresh_manifest is not None + assert capability.process_calls == 1 + client = _FakeClient(_FakeSession(Manifest())) + payload = client.serialize_session_state( + TestSessionState( + manifest=fresh_manifest, + snapshot=NoopSnapshot(id="resume"), + ) + ) + state = client._deserialize_session_state_payload(payload, TestSessionState) + capability.process_calls = 0 + agent = SandboxAgent(name="worker", model=FakeModel(), instructions="Worker.") + + processed = SandboxRuntimeSessionManager._process_resumed_state_manifest( + agent=agent, + capabilities=[capability], + session_state=state, + trusted_manifest=base_manifest, + ) + + mount = processed.manifest.entries["remote"] + assert isinstance(mount, S3Mount) + assert mount.bucket == "trusted-bucket" + assert mount.access_key_id == "trusted-access" + assert mount.secret_access_key == "trusted-secret" + assert capability.process_calls == 1 + processed.assert_trusted_manifest_rebound() + + manifest_payload = cast(dict[str, object], payload["manifest"]) + entries_payload = cast(dict[str, object], manifest_payload["entries"]) + mount_payload = cast(dict[str, object], entries_payload["remote"]) + mount_payload["bucket"] = "attacker-bucket" + tampered_state = client._deserialize_session_state_payload(payload, TestSessionState) + capability.process_calls = 0 + + with pytest.raises(ValueError, match="does not match current trusted configuration"): + SandboxRuntimeSessionManager._process_resumed_state_manifest( + agent=agent, + capabilities=[capability], + session_state=tampered_state, + trusted_manifest=base_manifest, + ) + + mount_payload["bucket"] = "trusted-bucket" + entries_payload["extra"] = S3Mount( + bucket="extra-bucket", + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ).model_dump(mode="json") + extra_mount_state = client._deserialize_session_state_payload(payload, TestSessionState) + + with pytest.raises(ValueError, match="mount topology does not match"): + SandboxRuntimeSessionManager._process_resumed_state_manifest( + agent=agent, + capabilities=[capability], + session_state=extra_mount_state, + trusted_manifest=base_manifest, + ) + + @pytest.mark.asyncio async def test_session_manager_rejects_unmarked_serialized_host_path( tmp_path: Path, diff --git a/tests/sandbox/test_session_state_roundtrip.py b/tests/sandbox/test_session_state_roundtrip.py index cab98a9b12..8e09af9f1d 100644 --- a/tests/sandbox/test_session_state_roundtrip.py +++ b/tests/sandbox/test_session_state_roundtrip.py @@ -17,6 +17,8 @@ from pydantic import ConfigDict, ValidationError, field_serializer, field_validator from agents.sandbox import Manifest, SandboxPathGrant +from agents.sandbox.entries import File, InContainerMountStrategy, RcloneMountPattern, S3Mount +from agents.sandbox.errors import MountConfigError from agents.sandbox.manifest import EnvEntry, Environment, EnvValue, StrEnvValue from agents.sandbox.session import ( BaseSandboxClient, @@ -82,7 +84,7 @@ async def delete(self, session: SandboxSession) -> SandboxSession: raise AssertionError("delete() is not used by round-trip tests") async def resume(self, state: SandboxSessionState) -> SandboxSession: - state.assert_path_grants_rebound() + state.assert_trusted_manifest_rebound() self.resume_state = state return cast(SandboxSession, object()) @@ -353,7 +355,7 @@ def test_client_serialization_redacts_host_paths_and_rebinds_from_trusted_manife ) ) state = _SimpleSessionState( - manifest=trusted_manifest, + manifest=trusted_manifest.model_copy(update={"root": "/runtime/workspace"}), snapshot=NoopSnapshot(id="snapshot"), ) @@ -396,6 +398,357 @@ async def test_path_only_grants_preserve_direct_client_resume_roundtrip(self) -> assert client.resume_state is not None assert client.resume_state.manifest.extra_path_grants == manifest.extra_path_grants + def test_client_serialization_redacts_mount_credentials_and_rebinds_from_trusted_manifest( + self, + ) -> None: + client = _RoundTripClient() + trusted_manifest = Manifest( + entries={ + "remote": S3Mount( + bucket="example", + access_key_id="access-key-sentinel", + secret_access_key="secret-key-sentinel", + session_token="session-token-sentinel", + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ) + } + ).with_in_container_mount_credential_exposure_allowed("remote") + state = _SimpleSessionState( + manifest=trusted_manifest, + snapshot=NoopSnapshot(id="snapshot"), + ) + + payload = client.serialize_session_state(state) + encoded = json.dumps(payload) + + assert "access-key-sentinel" not in encoded + assert "secret-key-sentinel" not in encoded + assert "session-token-sentinel" not in encoded + assert payload["__openai_agents_redacted_mount_credential_paths"] == ["remote"] + + restored = client.deserialize_session_state(payload) + restored_mount = restored.manifest.entries["remote"] + assert isinstance(restored_mount, S3Mount) + assert restored_mount._configured_credential_fields() == () + assert restored.mount_credentials_require_rebind == ("remote",) + + rebound = restored.rebind_persisted_mount_credentials(trusted_manifest) + rebound_mount = rebound.manifest.entries["remote"] + assert isinstance(rebound_mount, S3Mount) + assert rebound_mount.access_key_id == "access-key-sentinel" + assert rebound_mount.secret_access_key == "secret-key-sentinel" + assert rebound_mount.session_token == "session-token-sentinel" + assert rebound.mount_credentials_require_rebind == () + rebound.assert_trusted_manifest_rebound() + + @pytest.mark.asyncio + async def test_direct_resume_requires_mount_credential_rebind(self) -> None: + client = _RoundTripClient() + trusted_manifest = Manifest( + entries={ + "remote": S3Mount( + bucket="example", + access_key_id="access-key", + secret_access_key="secret-key", + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ) + } + ).with_in_container_mount_credential_exposure_allowed("remote") + state = _SimpleSessionState( + manifest=trusted_manifest, + snapshot=NoopSnapshot(id="snapshot"), + ) + restored = client.deserialize_session_state(client.serialize_session_state(state)) + + with pytest.raises(ValueError, match="mount credentials must be rebound"): + await client.resume(restored) + + await client.resume(restored.rebind_persisted_mount_credentials(trusted_manifest)) + + def test_unmarked_serialized_mount_credentials_are_discarded(self) -> None: + client = _RoundTripClient() + trusted_manifest = Manifest( + entries={ + "remote": S3Mount( + bucket="example", + access_key_id="access-key-sentinel", + secret_access_key="secret-key-sentinel", + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ) + } + ) + state = _SimpleSessionState( + manifest=trusted_manifest, + snapshot=NoopSnapshot(id="snapshot"), + ) + payload = cast(dict[str, object], state.model_dump(mode="json")) + + restored = client.deserialize_session_state(payload) + + assert "access-key-sentinel" not in restored.model_dump_json() + assert "secret-key-sentinel" not in restored.model_dump_json() + assert restored.mount_credentials_require_rebind == ("remote",) + + def test_credentialless_mount_with_canonical_nulls_does_not_require_rebind(self) -> None: + client = _RoundTripClient() + state = _SimpleSessionState( + manifest=Manifest( + entries={ + "remote": S3Mount( + bucket="example", + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ) + } + ), + snapshot=NoopSnapshot(id="snapshot"), + ) + + payload = client.serialize_session_state(state) + restored = client.deserialize_session_state(payload) + emitted = client.serialize_session_state(restored) + + assert "__openai_agents_redacted_mount_credential_paths" not in payload + assert restored.mount_credentials_require_rebind == () + assert "__openai_agents_redacted_mount_credential_paths" not in emitted + restored.assert_trusted_manifest_rebound() + + def test_raw_mount_credentials_are_removed_before_validation(self) -> None: + client = _RoundTripClient() + trusted_manifest = Manifest( + entries={ + "remote": S3Mount( + bucket="example", + access_key_id="access-key", + secret_access_key="secret-key", + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ) + } + ) + state = _SimpleSessionState( + manifest=trusted_manifest, + snapshot=NoopSnapshot(id="snapshot"), + ) + payload = cast(dict[str, object], state.model_dump(mode="json")) + manifest_payload = cast(dict[str, object], payload["manifest"]) + entries_payload = cast(dict[str, object], manifest_payload["entries"]) + mount_payload = cast(dict[str, object], entries_payload["remote"]) + sentinel = "MALFORMED_SECRET_SENTINEL" + mount_payload["access_key_id"] = {"malformed": sentinel} + + restored = client.deserialize_session_state(payload) + parsed = SandboxSessionState.parse(payload) + + assert sentinel not in restored.model_dump_json() + assert restored.mount_credentials_require_rebind == ("remote",) + assert sentinel not in parsed.model_dump_json() + assert parsed.mount_credentials_require_rebind == ("remote",) + + @pytest.mark.parametrize("entry_type", [None, "unknown_mount"]) + def test_malformed_entry_credentials_are_removed_before_validation( + self, + entry_type: str | None, + ) -> None: + sentinel = "MALFORMED_ENTRY_SECRET_SENTINEL" + raw_entry: dict[str, object] = { + "mount_strategy": "invalid", + "client_secret": sentinel, + } + if entry_type is not None: + raw_entry["type"] = entry_type + payload: dict[str, object] = { + "type": "simple-roundtrip", + "manifest": {"entries": {"remote": raw_entry}}, + "snapshot": {"type": "noop", "id": "snapshot"}, + } + + with pytest.raises((TypeError, ValueError, ValidationError)) as exc_info: + SandboxSessionState.parse(payload) + + assert sentinel not in repr(exc_info.value) + + def test_malformed_rclone_pattern_source_is_removed_before_validation(self) -> None: + client = _RoundTripClient() + sentinel = "MALFORMED_RCLONE_SOURCE_SENTINEL" + payload: dict[str, object] = { + "type": "simple-roundtrip", + "manifest": { + "entries": { + "remote": { + "type": "s3_mount", + "bucket": "example", + "mount_strategy": { + "type": "in_container", + "pattern": [{"config_file_path": sentinel}], + }, + } + } + }, + "snapshot": {"type": "noop", "id": "snapshot"}, + } + + with pytest.raises((TypeError, ValueError, ValidationError)) as parse_error: + SandboxSessionState.parse(payload) + with pytest.raises((TypeError, ValueError, ValidationError)) as client_error: + client.deserialize_session_state(payload) + + assert sentinel not in repr(parse_error.value) + assert sentinel not in repr(client_error.value) + + def test_rebind_rejects_persisted_mount_destination_changes(self) -> None: + client = _RoundTripClient() + trusted_manifest = Manifest( + entries={ + "remote": S3Mount( + bucket="trusted-bucket", + endpoint_url="https://trusted.example.com", + access_key_id="trusted-access", + secret_access_key="trusted-secret", + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ) + } + ).with_in_container_mount_credential_exposure_allowed("remote") + state = _SimpleSessionState( + manifest=trusted_manifest, + snapshot=NoopSnapshot(id="snapshot"), + ) + payload = client.serialize_session_state(state) + manifest_payload = cast(dict[str, object], payload["manifest"]) + entries_payload = cast(dict[str, object], manifest_payload["entries"]) + mount_payload = cast(dict[str, object], entries_payload["remote"]) + mount_payload["bucket"] = "attacker-bucket" + mount_payload["endpoint_url"] = "https://attacker.invalid" + restored = client.deserialize_session_state(payload) + + with pytest.raises(ValueError, match="does not match current trusted configuration"): + restored.rebind_persisted_mount_credentials(trusted_manifest) + + assert "trusted-access" not in restored.model_dump_json() + assert "trusted-secret" not in restored.model_dump_json() + + @pytest.mark.parametrize("mutation", ["extra", "missing", "tampered"]) + def test_rebind_rejects_complete_mount_topology_changes(self, mutation: str) -> None: + client = _RoundTripClient() + trusted_manifest = Manifest( + entries={ + "remote": S3Mount( + bucket="trusted-bucket", + access_key_id="trusted-access", + secret_access_key="trusted-secret", + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ), + "public": S3Mount( + bucket="public-bucket", + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ), + } + ).with_in_container_mount_credential_exposure_allowed("remote") + state = _SimpleSessionState( + manifest=trusted_manifest, + snapshot=NoopSnapshot(id="snapshot"), + ) + payload = client.serialize_session_state(state) + manifest_payload = cast(dict[str, object], payload["manifest"]) + entries_payload = cast(dict[str, object], manifest_payload["entries"]) + if mutation == "extra": + entries_payload["extra"] = S3Mount( + bucket="extra-bucket", + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ).model_dump(mode="json") + elif mutation == "missing": + entries_payload.pop("public") + else: + public_payload = cast(dict[str, object], entries_payload["public"]) + public_payload["bucket"] = "tampered-bucket" + restored = client.deserialize_session_state(payload) + + with pytest.raises(ValueError, match="does not match current trusted configuration"): + restored.rebind_persisted_mount_credentials(trusted_manifest) + + assert "trusted-access" not in restored.model_dump_json() + assert "trusted-secret" not in restored.model_dump_json() + + def test_serialization_rejects_manifest_owned_rclone_config_content(self) -> None: + client = _RoundTripClient() + sentinel = "RCLONE_SECRET_SENTINEL" + manifest = Manifest( + entries={ + "rclone.conf": File(content=f"[remote]\nsecret = {sentinel}\n".encode()), + "remote": S3Mount( + bucket="example", + mount_strategy=InContainerMountStrategy( + pattern=RcloneMountPattern(config_file_path=Path("rclone.conf")) + ), + ), + } + ).with_in_container_mount_credential_exposure_allowed("remote") + state = _SimpleSessionState( + manifest=manifest, + snapshot=NoopSnapshot(id="snapshot"), + ) + + with pytest.raises(MountConfigError, match="serialized manifest entry") as exc_info: + client.serialize_session_state(state) + + assert sentinel not in repr(exc_info.value) + + def test_raw_state_rejects_manifest_owned_rclone_config_content(self) -> None: + client = _RoundTripClient() + sentinel = "LEGACY_RCLONE_SECRET_SENTINEL" + state = _SimpleSessionState( + manifest=Manifest( + entries={ + "rclone.conf": File(content=f"[remote]\nsecret = {sentinel}\n".encode()), + "remote": S3Mount( + bucket="example", + mount_strategy=InContainerMountStrategy( + pattern=RcloneMountPattern(config_file_path=Path("rclone.conf")) + ), + ), + } + ), + snapshot=NoopSnapshot(id="snapshot"), + ) + payload = cast(dict[str, object], state.model_dump(mode="json")) + + with pytest.raises(ValueError, match="must not be a manifest entry") as parse_error: + SandboxSessionState.parse(payload) + with pytest.raises(ValueError, match="must not be a manifest entry") as client_error: + client.deserialize_session_state(payload) + + assert sentinel not in repr(parse_error.value) + assert sentinel not in repr(client_error.value) + + @pytest.mark.parametrize( + "marker", + ["MARKER_SECRET_SENTINEL", ["MARKER_SECRET_SENTINEL"]], + ) + def test_raw_state_rejects_invalid_mount_credential_markers_without_values( + self, + marker: object, + ) -> None: + client = _RoundTripClient() + state = _SimpleSessionState( + manifest=Manifest( + entries={ + "remote": S3Mount( + bucket="example", + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ) + } + ), + snapshot=NoopSnapshot(id="snapshot"), + ) + payload = cast(dict[str, object], state.model_dump(mode="json")) + payload["__openai_agents_redacted_mount_credential_paths"] = marker + + with pytest.raises(ValueError, match="mount credential marker") as parse_error: + SandboxSessionState.parse(payload) + with pytest.raises(ValueError, match="mount credential marker") as client_error: + client.deserialize_session_state(payload) + + assert "MARKER_SECRET_SENTINEL" not in repr(parse_error.value) + assert "MARKER_SECRET_SENTINEL" not in repr(client_error.value) + def test_client_state_roundtrip_does_not_deepcopy_extension_state(self) -> None: client = _RoundTripClient() trusted_manifest = Manifest( diff --git a/tests/test_run_state.py b/tests/test_run_state.py index 83bbdb7c1f..bffe069195 100644 --- a/tests/test_run_state.py +++ b/tests/test_run_state.py @@ -105,6 +105,7 @@ ) from agents.sandbox import Manifest from agents.sandbox.capabilities.capability import Capability +from agents.sandbox.entries import File, InContainerMountStrategy, RcloneMountPattern, S3Mount from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient, UnixLocalSandboxSessionState from agents.sandbox.session.base_sandbox_session import BaseSandboxSession from agents.sandbox.snapshot import LocalSnapshot, NoopSnapshot @@ -6079,6 +6080,411 @@ async def test_run_state_round_trip_preserves_serialized_sandbox_session_snapsho assert isinstance(restored_session_state.snapshot, LocalSnapshot) assert restored_session_state.snapshot.base_path == Path("/tmp/snapshots") + @pytest.mark.asyncio + async def test_released_run_state_sandbox_credentials_are_redacted_before_reemission( + self, + ) -> None: + agent = Agent(name="TestAgent") + state: RunState[Any, Agent[Any]] = make_state( + agent, + context=RunContextWrapper(context={}), + original_input="test", + ) + session_state: dict[str, object] = { + "type": "unix_local", + "manifest": { + "entries": { + "remote": { + "type": "s3_mount", + "bucket": "example", + "access_key_id": "LEGACY_RUNSTATE_ACCESS_SENTINEL", + "secret_access_key": "LEGACY_RUNSTATE_SECRET_SENTINEL", + "mount_strategy": { + "type": "in_container", + "pattern": {"type": "rclone"}, + }, + } + } + }, + "snapshot": {"type": "noop", "id": "snapshot"}, + } + payload = state.to_json() + payload["$schemaVersion"] = "1.13" + payload["sandbox"] = { + "backend_id": "unix_local", + "session_state": deepcopy(session_state), + "sessions_by_agent": { + "inactive-agent": { + "agent_name": "InactiveAgent", + "session_state": deepcopy(session_state), + } + }, + } + + restored = await RunState.from_json(agent, payload) + emitted = restored.to_json() + encoded = json.dumps(emitted) + + assert emitted["$schemaVersion"] == CURRENT_SCHEMA_VERSION + assert "LEGACY_RUNSTATE_ACCESS_SENTINEL" not in encoded + assert "LEGACY_RUNSTATE_SECRET_SENTINEL" not in encoded + sandbox_payload = cast(dict[str, object], emitted["sandbox"]) + top_level_state = cast(dict[str, object], sandbox_payload["session_state"]) + sessions_by_agent = cast(dict[str, object], sandbox_payload["sessions_by_agent"]) + inactive_entry = cast(dict[str, object], sessions_by_agent["inactive-agent"]) + inactive_state = cast(dict[str, object], inactive_entry["session_state"]) + expected_marker = ["remote"] + assert top_level_state["__openai_agents_redacted_mount_credential_paths"] == expected_marker + assert inactive_state["__openai_agents_redacted_mount_credential_paths"] == expected_marker + + @pytest.mark.asyncio + @pytest.mark.parametrize("legacy", [False, True]) + async def test_released_credentialless_mount_nulls_do_not_require_rebind( + self, + legacy: bool, + ) -> None: + agent = Agent(name="TestAgent") + state: RunState[Any, Agent[Any]] = make_state( + agent, + context=RunContextWrapper(context={}), + original_input="test", + ) + session_state = UnixLocalSandboxSessionState( + manifest=Manifest( + entries={ + "remote": S3Mount( + bucket="example", + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ) + } + ), + snapshot=NoopSnapshot(id="snapshot"), + ).model_dump(mode="json") + payload = state.to_json() + payload["$schemaVersion"] = "1.13" + payload["sandbox"] = ( + {"sessions_by_agent": {"inactive-agent": session_state}} + if legacy + else {"session_state": session_state} + ) + + restored = await RunState.from_json(agent, payload) + emitted = restored.to_json() + + assert "__openai_agents_redacted_mount_credential_paths" not in json.dumps(emitted) + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "sandbox_payload", + [ + ["MALFORMED_RUNSTATE_SECRET_SENTINEL"], + {"session_state": ["MALFORMED_RUNSTATE_SECRET_SENTINEL"]}, + {"sessions_by_agent": ["MALFORMED_RUNSTATE_SECRET_SENTINEL"]}, + {"sessions_by_agent": {"inactive-agent": ["MALFORMED_RUNSTATE_SECRET_SENTINEL"]}}, + { + "sessions_by_agent": { + "inactive-agent": {"session_state": ["MALFORMED_RUNSTATE_SECRET_SENTINEL"]} + } + }, + { + "session_state": { + "session_state": { + "type": "unix_local", + "manifest": { + "entries": { + "remote": { + "type": "s3_mount", + "access_key_id": "MALFORMED_RUNSTATE_SECRET_SENTINEL", + } + } + }, + "snapshot": {"type": "noop", "id": "snapshot"}, + } + } + }, + { + "sessions_by_agent": { + "inactive-agent": { + "agent_name": "InactiveAgent", + "session_state": { + "session_state": { + "type": "unix_local", + "manifest": { + "entries": { + "remote": { + "type": "s3_mount", + "access_key_id": "MALFORMED_RUNSTATE_SECRET_SENTINEL", + } + } + }, + "snapshot": {"type": "noop", "id": "snapshot"}, + } + }, + } + } + }, + { + "session_state": { + "type": "unix_local", + "manifest": {"entries": {}}, + "snapshot": {"type": "noop", "id": "snapshot"}, + "sessions_by_agent": { + "nested": { + "manifest": { + "entries": { + "remote": { + "type": "s3_mount", + "access_key_id": "MALFORMED_RUNSTATE_SECRET_SENTINEL", + } + } + } + } + }, + } + }, + { + "sessions_by_agent": { + "inactive-agent": { + "agent_name": "InactiveAgent", + "session_state": { + "type": "unix_local", + "manifest": {"entries": {}}, + "snapshot": {"type": "noop", "id": "snapshot"}, + "sessions_by_agent": { + "nested": { + "manifest": { + "entries": { + "remote": { + "type": "s3_mount", + "access_key_id": ( + "MALFORMED_RUNSTATE_SECRET_SENTINEL" + ), + } + } + } + } + }, + }, + } + } + }, + { + "session_state": { + "type": "unix_local", + "manifest": {"entries": {}}, + "snapshot": {"type": "noop", "id": "snapshot"}, + "metadata": { + "wrapped": { + "manifest": { + "entries": { + "remote": { + "type": "s3_mount", + "access_key_id": "MALFORMED_RUNSTATE_SECRET_SENTINEL", + } + } + } + } + }, + } + }, + { + "session_state": { + "type": "unix_local", + "manifest": {"entries": {}}, + "snapshot": {"type": "noop", "id": "snapshot"}, + }, + "manifest": { + "entries": { + "remote": { + "type": "s3_mount", + "access_key_id": "MALFORMED_RUNSTATE_SECRET_SENTINEL", + } + } + }, + }, + { + "backend_id": { + "manifest": { + "entries": { + "remote": { + "type": "s3_mount", + "access_key_id": "MALFORMED_RUNSTATE_SECRET_SENTINEL", + } + } + } + } + }, + { + "session_state": { + "type": "unix_local", + "manifest": { + "entries": {}, + "in_container_mount_credential_exposure_allowed_paths": [ + "MALFORMED_RUNSTATE_SECRET_SENTINEL" + ], + }, + "snapshot": {"type": "noop", "id": "snapshot"}, + } + }, + { + "sessions_by_agent": { + "inactive-agent": { + "type": "unix_local", + "manifest": { + "entries": {}, + "_in_container_mount_credential_exposure_allowed_paths": [ + "MALFORMED_RUNSTATE_SECRET_SENTINEL" + ], + }, + "snapshot": {"type": "noop", "id": "snapshot"}, + } + } + }, + { + "sessions_by_agent": { + "inactive-agent": { + "agent_name": "InactiveAgent", + "session_state": { + "type": "unix_local", + "manifest": {"entries": {}}, + "snapshot": {"type": "noop", "id": "snapshot"}, + }, + "manifest": { + "entries": { + "remote": { + "type": "s3_mount", + "access_key_id": "MALFORMED_RUNSTATE_SECRET_SENTINEL", + } + } + }, + } + } + }, + ], + ) + async def test_released_run_state_rejects_malformed_sandbox_envelopes_without_values( + self, + sandbox_payload: object, + ) -> None: + agent = Agent(name="TestAgent") + state: RunState[Any, Agent[Any]] = make_state( + agent, + context=RunContextWrapper(context={}), + original_input="test", + ) + payload = state.to_json() + payload["$schemaVersion"] = "1.13" + payload["sandbox"] = sandbox_payload + + with pytest.raises(ValueError, match="sandbox") as exc_info: + await RunState.from_json(agent, payload) + + assert "MALFORMED_RUNSTATE_SECRET_SENTINEL" not in repr(exc_info.value) + + @pytest.mark.asyncio + async def test_released_run_state_rejects_invalid_mount_credential_marker(self) -> None: + agent = Agent(name="TestAgent") + state: RunState[Any, Agent[Any]] = make_state( + agent, + context=RunContextWrapper(context={}), + original_input="test", + ) + payload = state.to_json() + payload["$schemaVersion"] = "1.13" + payload["sandbox"] = { + "session_state": { + "type": "unix_local", + "manifest": {"entries": {}}, + "snapshot": {"type": "noop", "id": "snapshot"}, + "__openai_agents_redacted_mount_credential_paths": ["MARKER_SECRET_SENTINEL"], + } + } + + with pytest.raises(ValueError, match="mount credential marker") as exc_info: + await RunState.from_json(agent, payload) + + assert "MARKER_SECRET_SENTINEL" not in repr(exc_info.value) + + @pytest.mark.asyncio + @pytest.mark.parametrize("inactive", [False, True]) + async def test_released_run_state_drops_noncanonical_manifest_metadata( + self, + inactive: bool, + ) -> None: + agent = Agent(name="TestAgent") + state: RunState[Any, Agent[Any]] = make_state( + agent, + context=RunContextWrapper(context={}), + original_input="test", + ) + session_state: dict[str, object] = { + "type": "unix_local", + "manifest": { + "entries": {}, + "metadata": { + "wrapped": { + "manifest": { + "entries": { + "remote": { + "type": "s3_mount", + "access_key_id": "NONCANONICAL_MANIFEST_SECRET_SENTINEL", + } + } + } + } + }, + }, + "snapshot": {"type": "noop", "id": "snapshot"}, + } + sandbox_payload: dict[str, object] + if inactive: + sandbox_payload = {"sessions_by_agent": {"inactive-agent": session_state}} + else: + sandbox_payload = {"session_state": session_state} + payload = state.to_json() + payload["$schemaVersion"] = "1.13" + payload["sandbox"] = sandbox_payload + + restored = await RunState.from_json(agent, payload) + emitted = restored.to_json() + + assert "NONCANONICAL_MANIFEST_SECRET_SENTINEL" not in json.dumps(emitted) + assert "metadata" not in json.dumps(emitted["sandbox"]) + + @pytest.mark.asyncio + async def test_released_run_state_rejects_manifest_owned_rclone_config(self) -> None: + agent = Agent(name="TestAgent") + state: RunState[Any, Agent[Any]] = make_state( + agent, + context=RunContextWrapper(context={}), + original_input="test", + ) + sentinel = "LEGACY_RUNSTATE_RCLONE_SECRET_SENTINEL" + session_state = UnixLocalSandboxSessionState( + manifest=Manifest( + entries={ + "rclone.conf": File(content=f"[remote]\nsecret = {sentinel}\n".encode()), + "remote": S3Mount( + bucket="example", + mount_strategy=InContainerMountStrategy( + pattern=RcloneMountPattern(config_file_path=Path("rclone.conf")) + ), + ), + } + ), + snapshot=NoopSnapshot(id="snapshot"), + ) + payload = state.to_json() + payload["$schemaVersion"] = "1.13" + payload["sandbox"] = { + "session_state": cast(dict[str, object], session_state.model_dump(mode="json")) + } + + with pytest.raises(ValueError, match="must not be a manifest entry") as exc_info: + await RunState.from_json(agent, payload) + + assert sentinel not in repr(exc_info.value) + @pytest.mark.asyncio async def test_from_json_agent_not_found(self): """Test that from_json raises error when agent is not found in agent map.""" From ab16ee01d679fe1a04e5df90d0455dbd5c1aa43a Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 7 Aug 2026 15:59:44 +0900 Subject: [PATCH 2/4] fix(sandbox): protect mount credentials from sandbox tools --- examples/sandbox/extensions/README.md | 2 +- src/agents/exceptions.py | 57 +- .../extensions/sandbox/blaxel/mounts.py | 202 ++- .../extensions/sandbox/blaxel/sandbox.py | 50 +- .../extensions/sandbox/cloudflare/sandbox.py | 27 +- .../extensions/sandbox/daytona/mounts.py | 5 +- .../extensions/sandbox/daytona/sandbox.py | 7 +- src/agents/extensions/sandbox/e2b/sandbox.py | 13 +- src/agents/extensions/sandbox/modal/mounts.py | 8 +- .../extensions/sandbox/modal/sandbox.py | 90 +- .../extensions/sandbox/runloop/sandbox.py | 14 +- .../extensions/sandbox/vercel/mounts.py | 2 +- .../extensions/sandbox/vercel/sandbox.py | 37 +- src/agents/run_internal/error_handlers.py | 3 +- src/agents/run_state.py | 6 +- src/agents/sandbox/_mount_security.py | 1289 +++++++++++++-- src/agents/sandbox/entries/base.py | 3 +- src/agents/sandbox/entries/mounts/base.py | 914 ++++++++++- src/agents/sandbox/entries/mounts/patterns.py | 395 +++-- .../entries/mounts/providers/azure_blob.py | 21 +- .../sandbox/entries/mounts/providers/box.py | 1 + .../sandbox/entries/mounts/providers/gcs.py | 5 +- .../sandbox/entries/mounts/providers/r2.py | 3 +- .../sandbox/entries/mounts/providers/s3.py | 8 +- .../entries/mounts/providers/s3_files.py | 14 + src/agents/sandbox/manifest.py | 97 +- src/agents/sandbox/runtime_session_manager.py | 64 +- src/agents/sandbox/sandboxes/docker.py | 92 +- src/agents/sandbox/sandboxes/unix_local.py | 4 + .../sandbox/session/base_sandbox_session.py | 14 + src/agents/sandbox/session/sandbox_client.py | 28 +- src/agents/sandbox/session/sandbox_session.py | 3 + .../sandbox/session/sandbox_session_state.py | 64 +- tests/extensions/sandbox/test_blaxel.py | 140 ++ .../extensions/sandbox/test_blaxel_mounts.py | 183 ++- tests/extensions/sandbox/test_cloudflare.py | 89 + tests/extensions/sandbox/test_daytona.py | 28 + tests/extensions/sandbox/test_e2b.py | 103 +- tests/extensions/sandbox/test_modal.py | 138 +- tests/extensions/sandbox/test_runloop.py | 44 +- tests/extensions/sandbox/test_vercel.py | 183 +-- tests/sandbox/integration_tests/_helpers.py | 14 +- tests/sandbox/test_compatibility_guards.py | 1 + tests/sandbox/test_docker.py | 154 +- tests/sandbox/test_manifest.py | 234 ++- tests/sandbox/test_mount_security.py | 1200 +++++++++++++- tests/sandbox/test_mounts.py | 434 ++++- tests/sandbox/test_runtime.py | 255 ++- tests/sandbox/test_session_state_roundtrip.py | 613 ++++++- tests/test_run_state.py | 1456 +++++++++++++++-- tests/test_tracing_errors.py | 16 + 51 files changed, 8021 insertions(+), 806 deletions(-) diff --git a/examples/sandbox/extensions/README.md b/examples/sandbox/extensions/README.md index c53380bfc4..5fd3a3df82 100644 --- a/examples/sandbox/extensions/README.md +++ b/examples/sandbox/extensions/README.md @@ -8,7 +8,7 @@ They intentionally keep the flow simple: 2. Create a `SandboxAgent` that inspects that workspace through one shell tool. 3. Run the agent against E2B, Modal, Daytona, Cloudflare, Runloop, Blaxel, or Vercel. -For cloud storage, prefer provider-native mounts or mounts established outside the sandbox. Hosted strategies that execute `rclone`, `s3fs`, `gcsfuse`, or similar helpers inside the model-controlled sandbox accept credentialless configurations, but explicit mount credentials are rejected by default. If a backend has no external or provider-native alternative, credential-bearing mounts are unsupported. +For cloud storage, prefer provider-native mounts or mounts established outside the sandbox. Hosted strategies that execute `rclone`, `s3fs`, `gcsfuse`, or similar helpers inside the model-controlled sandbox accept credentialless configurations, but explicit mount credentials are rejected. Backends without an external or provider-controlled mount mechanism do not support credentialed mounts. All of these examples require `OPENAI_API_KEY`, because they call the model through the normal `Runner` path. Each cloud backend also needs its own provider credentials. diff --git a/src/agents/exceptions.py b/src/agents/exceptions.py index 887ea910ba..09d4029abe 100644 --- a/src/agents/exceptions.py +++ b/src/agents/exceptions.py @@ -1,8 +1,10 @@ from __future__ import annotations import traceback +from collections.abc import Callable, Coroutine from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Any, NoReturn +from functools import wraps +from typing import TYPE_CHECKING, Any, NoReturn, ParamSpec, TypeVar if TYPE_CHECKING: from .agent import Agent @@ -23,6 +25,9 @@ _DATA_REDACTED_ATTR = "_agents_data_redacted" _DATA_REDACTED_ERROR_MESSAGE = "Error details are redacted." +_P = ParamSpec("_P") +_T = TypeVar("_T") + def _mark_error_to_drain_stream_events(error: Exception) -> None: setattr(error, _DRAIN_STREAM_EVENTS_ATTR, True) @@ -32,29 +37,69 @@ def _should_drain_stream_events_before_raising(error: Exception) -> bool: return bool(getattr(error, _DRAIN_STREAM_EVENTS_ATTR, False)) -def _mark_error_data_redacted(error: Exception) -> None: +def _mark_error_data_redacted(error: BaseException) -> None: setattr(error, _DATA_REDACTED_ATTR, True) -def _is_error_data_redacted(error: Exception) -> bool: +def _is_error_data_redacted(error: BaseException) -> bool: return bool(getattr(error, _DATA_REDACTED_ATTR, False)) -def _clear_data_redacted_error_traceback(error: Exception) -> None: +def _clear_data_redacted_error_traceback(error: BaseException) -> None: if _is_error_data_redacted(error) and error.__traceback__ is not None: traceback.clear_frames(error.__traceback__) -def _detach_data_redacted_error_traceback(error: Exception) -> None: +def _detach_data_redacted_error_traceback(error: BaseException) -> None: if _is_error_data_redacted(error): error.__traceback__ = None -def _raise_data_redacted_error(error: Exception) -> NoReturn: +def _raise_data_redacted_error(error: BaseException) -> NoReturn: """Raise a detached redacted error from a frame that owns no payload data.""" raise error from None +def _data_redacted_async_boundary( + function: Callable[_P, Coroutine[Any, Any, _T]], +) -> Callable[_P, Coroutine[Any, Any, _T]]: + """Clear sensitive operation frames before an error crosses a public async boundary.""" + + @wraps(function) + async def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _T: + try: + return await function(*args, **kwargs) + except BaseException as error: + _mark_error_data_redacted(error) + error.__cause__ = None + error.__context__ = None + _clear_data_redacted_error_traceback(error) + _detach_data_redacted_error_traceback(error) + del args, kwargs + _raise_data_redacted_error(error) + + return wrapper + + +def _data_redacted_boundary(function: Callable[_P, _T]) -> Callable[_P, _T]: + """Clear sensitive operation frames before an error crosses a public boundary.""" + + @wraps(function) + def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _T: + try: + return function(*args, **kwargs) + except BaseException as error: + _mark_error_data_redacted(error) + error.__cause__ = None + error.__context__ = None + _clear_data_redacted_error_traceback(error) + _detach_data_redacted_error_traceback(error) + del args, kwargs + _raise_data_redacted_error(error) + + return wrapper + + @dataclass class RunErrorDetails: """Data collected from an agent run when an exception occurs.""" diff --git a/src/agents/extensions/sandbox/blaxel/mounts.py b/src/agents/extensions/sandbox/blaxel/mounts.py index adad1dff8c..fcf0798a01 100644 --- a/src/agents/extensions/sandbox/blaxel/mounts.py +++ b/src/agents/extensions/sandbox/blaxel/mounts.py @@ -17,6 +17,7 @@ from __future__ import annotations +import asyncio import io import logging import shlex @@ -24,13 +25,18 @@ import warnings from dataclasses import dataclass from pathlib import Path -from typing import Any, ClassVar, Literal +from typing import Any, ClassVar, Literal, cast from .... import _debug +from ....exceptions import _data_redacted_async_boundary from ....logger import log_tool_action_warning from ....sandbox.entries import GCSMount, Mount, R2Mount, S3Mount from ....sandbox.entries.mounts.base import MountStrategyBase -from ....sandbox.errors import MountConfigError +from ....sandbox.entries.mounts.patterns import ( + _redact_sensitive_values as _redact_mount_sensitive_values, + _write_sensitive_config_file, +) +from ....sandbox.errors import MountCommandError, MountConfigError from ....sandbox.materialization import MaterializedFile from ....sandbox.session.base_sandbox_session import BaseSandboxSession from ....sandbox.types import FileMode, Permissions @@ -76,6 +82,7 @@ class BlaxelCloudBucketMountStrategy(MountStrategyBase): def validate_mount(self, mount: Mount) -> None: _build_mount_config(mount, mount_path="/validate") + @_data_redacted_async_boundary async def activate( self, mount: Mount, @@ -301,10 +308,21 @@ async def _write_mount_credential_file( normalized_path = session.normalize_path(credential_path) credential_path_str = sandbox_path_str(normalized_path) try: - await session.write(credential_path, io.BytesIO(content.encode("utf-8"))) - await session._exec_checked_nonzero("chmod", "0600", credential_path_str) - except BaseException: - await _remove_mount_credential_file(session, credential_path_str) + await _write_sensitive_config_file(session, credential_path, content.encode("utf-8")) + except MountCommandError as error: + if error.context.get("cleanup_confirmed") is not False: + raise + result, cleanup_cancelled = await _run_mount_credential_cleanup( + session, + credential_path=credential_path_str, + mount_path="", + mount_may_be_attached=False, + ) + mount_detached, credential_revoked, termination_confirmed = result + if not ((mount_detached and credential_revoked) or termination_confirmed): + raise _mount_credential_cleanup_error(result) from None + if error.context.get("cancellation_requested") is True or cleanup_cancelled: + raise asyncio.CancelledError() from None raise return credential_path_str @@ -316,13 +334,133 @@ async def _remove_mount_credential_file( await session._exec_checked_nonzero("rm", "-f", credential_path) +async def _recover_mount_credential_cleanup( + session: BaseSandboxSession, + *, + credential_path: str, + mount_path: str, + mount_may_be_attached: bool, +) -> tuple[bool, bool, bool]: + mount_detached = not mount_may_be_attached + credential_revoked = False + if mount_may_be_attached: + try: + mount_detached = await _unmount_bucket(session, mount_path) + except (Exception, asyncio.CancelledError): + pass + try: + await session.write(Path(credential_path), io.BytesIO(b"")) + credential_revoked = True + except (Exception, asyncio.CancelledError): + pass + try: + await _remove_mount_credential_file(session, credential_path) + credential_revoked = True + except (Exception, asyncio.CancelledError): + pass + + session_invalidated = not (mount_detached and credential_revoked) + termination_confirmed = False + if session_invalidated: + force_terminate = getattr(session, "_force_terminate_after_mount_credential_failure", None) + if force_terminate is None: + cast(Any, session)._mount_credential_cleanup_failed = True + else: + try: + termination_confirmed = bool(await force_terminate()) + except (Exception, asyncio.CancelledError): + pass + return mount_detached, credential_revoked, termination_confirmed + + +async def _run_mount_credential_cleanup( + session: BaseSandboxSession, + *, + credential_path: str, + mount_path: str, + mount_may_be_attached: bool, +) -> tuple[tuple[bool, bool, bool], bool]: + """Complete detach/revoke cleanup before propagating caller cancellation.""" + + cleanup_task = asyncio.create_task( + _recover_mount_credential_cleanup( + session, + credential_path=credential_path, + mount_path=mount_path, + mount_may_be_attached=mount_may_be_attached, + ) + ) + cancellation_seen = False + while True: + try: + result = await asyncio.shield(cleanup_task) + return result, cancellation_seen + except asyncio.CancelledError: + if cleanup_task.done(): + return cleanup_task.result(), True + cancellation_seen = True + + +def _mount_credential_cleanup_error( + result: tuple[bool, bool, bool], +) -> MountConfigError: + mount_detached, credential_revoked, termination_confirmed = result + return MountConfigError( + message="Blaxel mount credential cleanup could not complete normally", + context={ + "mount_detached": mount_detached, + "credential_revoked": credential_revoked, + "session_invalidated": not (mount_detached and credential_revoked), + "termination_confirmed": termination_confirmed, + }, + ) + + +async def _finalize_mount_credentials( + session: BaseSandboxSession, + *, + credential_path: str, + mount_path: str, + mount_attempted: bool, + operation_succeeded: bool, + operation_cancelled: bool, +) -> None: + recovery_required = not operation_succeeded + cancellation_seen = operation_cancelled + if operation_succeeded: + try: + await _remove_mount_credential_file(session, credential_path) + except asyncio.CancelledError: + cancellation_seen = True + recovery_required = True + except Exception: + recovery_required = True + + if not recovery_required: + return + + result, cleanup_cancelled = await _run_mount_credential_cleanup( + session, + credential_path=credential_path, + mount_path=mount_path, + mount_may_be_attached=mount_attempted, + ) + cancellation_seen = cancellation_seen or cleanup_cancelled + mount_detached, credential_revoked, termination_confirmed = result + cleanup_secured = (mount_detached and credential_revoked) or termination_confirmed + if not cleanup_secured: + raise _mount_credential_cleanup_error(result) from None + if cancellation_seen: + raise asyncio.CancelledError() from None + if operation_succeeded: + raise _mount_credential_cleanup_error(result) from None + + def _redact_sensitive_values(text: str, values: list[str | None]) -> str: - redacted = text - for value in sorted({value for value in values if value}, key=len, reverse=True): - redacted = redacted.replace(value, "REDACTED") - return redacted + return _redact_mount_sensitive_values(text, [value for value in values if value]) +@_data_redacted_async_boundary async def _mount_s3(session: BaseSandboxSession, config: BlaxelCloudBucketMountConfig) -> None: """Mount an S3 or R2 bucket using s3fs-fuse.""" await _ensure_tool(session, "s3fs") @@ -365,8 +503,12 @@ async def _mount_s3(session: BaseSandboxSession, config: BlaxelCloudBucketMountC opts_str = ",".join(opts) cmd = f"s3fs {shlex.quote(bucket)} {mount_path} -o {shlex.quote(opts_str)}" + mount_attempted = False + operation_succeeded = False + operation_cancelled = False try: await _exec(session, f"mkdir -p {mount_path}") + mount_attempted = True result = await _exec(session, cmd, timeout=60) if result.exit_code != 0: stderr = result.stderr.decode("utf-8", errors="replace") if result.stderr else "" @@ -378,12 +520,23 @@ async def _mount_s3(session: BaseSandboxSession, config: BlaxelCloudBucketMountC message="s3fs mount failed", context={"cmd": cmd, "exit_code": result.exit_code, "stderr": stderr}, ) + operation_succeeded = True + except asyncio.CancelledError: + operation_cancelled = True + raise finally: - # Clean up credentials file. if cred_path: - await _remove_mount_credential_file(session, cred_path) + await _finalize_mount_credentials( + session, + credential_path=cred_path, + mount_path=config.mount_path, + mount_attempted=mount_attempted, + operation_succeeded=operation_succeeded, + operation_cancelled=operation_cancelled, + ) +@_data_redacted_async_boundary async def _mount_gcs(session: BaseSandboxSession, config: BlaxelCloudBucketMountConfig) -> None: """Mount a GCS bucket using gcsfuse.""" await _ensure_tool(session, "gcsfuse") @@ -415,8 +568,12 @@ async def _mount_gcs(session: BaseSandboxSession, config: BlaxelCloudBucketMount opts_str = " ".join(opts) cmd = f"gcsfuse {opts_str} {bucket} {mount_path}" + mount_attempted = False + operation_succeeded = False + operation_cancelled = False try: await _exec(session, f"mkdir -p {mount_path}") + mount_attempted = True result = await _exec(session, cmd, timeout=60) if result.exit_code != 0: stderr = result.stderr.decode("utf-8", errors="replace") if result.stderr else "" @@ -425,9 +582,20 @@ async def _mount_gcs(session: BaseSandboxSession, config: BlaxelCloudBucketMount message="gcsfuse mount failed", context={"cmd": cmd, "exit_code": result.exit_code, "stderr": stderr}, ) + operation_succeeded = True + except asyncio.CancelledError: + operation_cancelled = True + raise finally: if key_path: - await _remove_mount_credential_file(session, key_path) + await _finalize_mount_credentials( + session, + credential_path=key_path, + mount_path=config.mount_path, + mount_attempted=mount_attempted, + operation_succeeded=operation_succeeded, + operation_cancelled=operation_cancelled, + ) async def _mount_bucket(session: BaseSandboxSession, config: BlaxelCloudBucketMountConfig) -> None: @@ -443,13 +611,13 @@ async def _mount_bucket(session: BaseSandboxSession, config: BlaxelCloudBucketMo ) -async def _unmount_bucket(session: BaseSandboxSession, mount_path: str) -> None: +async def _unmount_bucket(session: BaseSandboxSession, mount_path: str) -> bool: """Unmount a FUSE mount point. Tries fusermount first, falls back to umount.""" path = shlex.quote(mount_path) # Try fusermount (FUSE-aware). result = await _exec(session, f"fusermount -u {path}") if result.exit_code == 0: - return + return True if _debug.DONT_LOG_TOOL_DATA: logger.debug("fusermount failed (exit %d), trying umount", result.exit_code) else: @@ -461,7 +629,7 @@ async def _unmount_bucket(session: BaseSandboxSession, mount_path: str) -> None: # Fallback to regular umount. result = await _exec(session, f"umount {path}") if result.exit_code == 0: - return + return True if _debug.DONT_LOG_TOOL_DATA: logger.debug("umount failed (exit %d), trying lazy umount", result.exit_code) else: @@ -481,6 +649,8 @@ async def _unmount_bucket(session: BaseSandboxSession, mount_path: str) -> None: mount_path, result.exit_code, ) + return False + return True # --------------------------------------------------------------------------- diff --git a/src/agents/extensions/sandbox/blaxel/sandbox.py b/src/agents/extensions/sandbox/blaxel/sandbox.py index 9ee712282c..cf34e926bf 100644 --- a/src/agents/extensions/sandbox/blaxel/sandbox.py +++ b/src/agents/extensions/sandbox/blaxel/sandbox.py @@ -29,12 +29,14 @@ from pydantic import BaseModel, Field +from ....exceptions import _data_redacted_async_boundary, _data_redacted_boundary from ....logger import log_tool_action_debug, log_tool_action_warning from ....sandbox.entries import Mount from ....sandbox.errors import ( ExecTimeoutError, ExecTransportError, ExposedPortUnavailableError, + MountConfigError, WorkspaceArchiveReadError, WorkspaceArchiveWriteError, WorkspaceReadNotFoundError, @@ -292,6 +294,15 @@ class BlaxelSandboxSessionState(SandboxSessionState): sandbox_url: str | None = None exposed_port_public: bool = True exposed_port_url_ttl_s: int = 3600 + mount_credential_cleanup_failed: bool = False + + def assert_mount_credentials_safe(self) -> None: + """Reject every attempt to reuse a sandbox whose credential cleanup is terminal.""" + + if self.mount_credential_cleanup_failed: + raise MountConfigError( + message="Blaxel sandbox session is unavailable after mount credential cleanup" + ) # --------------------------------------------------------------------------- @@ -343,6 +354,21 @@ def __init__( self._pty_sessions = {} self._reserved_pty_process_ids = set() + def _assert_mount_credentials_safe(self) -> None: + self.state.assert_mount_credentials_safe() + + async def _force_terminate_after_mount_credential_failure(self) -> bool: + self.state.mount_credential_cleanup_failed = True + try: + await self.pty_terminate_all() + except (Exception, asyncio.CancelledError): + pass + try: + await self._sandbox.delete() + except (Exception, asyncio.CancelledError): + return False + return True + @classmethod def from_state( cls, @@ -364,6 +390,7 @@ def _assert_exposed_port_configured(self, port: int) -> None: pass async def _resolve_exposed_port(self, port: int) -> ExposedPortEndpoint: + self._assert_mount_credentials_safe() is_public = self.state.exposed_port_public try: preview = await self._sandbox.previews.create_if_not_exists( @@ -434,6 +461,7 @@ async def _resolve_exposed_port(self, port: int) -> ExposedPortEndpoint: # -- lifecycle ----------------------------------------------------------- async def start(self) -> None: + self._assert_mount_credentials_safe() # When resuming a paused sandbox, _skip_start is set by the client to # avoid reapplying the full manifest over files that may have changed # while the sandbox was paused. @@ -465,7 +493,7 @@ async def stop(self) -> None: async def shutdown(self) -> None: await self.pty_terminate_all() try: - if not self.state.pause_on_exit: + if self.state.mount_credential_cleanup_failed or not self.state.pause_on_exit: await self._sandbox.delete() # When pause_on_exit is True the sandbox is kept alive. Blaxel # automatically resumes it on the next connection. @@ -473,6 +501,7 @@ async def shutdown(self) -> None: log_tool_action_warning(logger, "Sandbox delete failed during shutdown", e) async def _validate_path_access(self, path: Path | str, *, for_write: bool = False) -> Path: + self._assert_mount_credentials_safe() return await self._validate_remote_path_access(path, for_write=for_write) def _runtime_helpers(self) -> tuple[RuntimeHelperScript, ...]: @@ -568,6 +597,7 @@ async def _exec_internal( *command: str | Path, timeout: float | None = None, ) -> ExecResult: + self._assert_mount_credentials_safe() cmd_str = shlex.join(str(c) for c in command) cwd = self.state.manifest.root exec_timeout = self._coerce_exec_timeout(timeout) @@ -625,6 +655,8 @@ async def _exec_internal( # -- running check ------------------------------------------------------- async def running(self) -> bool: + if self.state.mount_credential_cleanup_failed: + return False try: await asyncio.wait_for(self._sandbox.fs.ls("/"), timeout=10.0) return True @@ -634,6 +666,13 @@ async def running(self) -> bool: # -- workspace persistence ----------------------------------------------- + def _persist_workspace_skip_relpaths(self) -> set[Path]: + from .mounts import _CREDENTIAL_DIRECTORY + + skip_paths = super()._persist_workspace_skip_relpaths() + skip_paths.add(_CREDENTIAL_DIRECTORY) + return skip_paths + def _tar_exclude_args(self) -> list[str]: return shell_tar_exclude_args(self._persist_workspace_skip_relpaths()) @@ -784,6 +823,7 @@ async def pty_exec_start( yield_time_s: float | None = None, max_output_tokens: int | None = None, ) -> PtyExecUpdate: + self._assert_mount_credentials_safe() aiohttp = _import_aiohttp() sanitized = self._prepare_exec_command(*command, shell=shell, user=user) cmd_str = shlex.join(str(part) for part in sanitized) @@ -873,6 +913,7 @@ async def pty_write_stdin( yield_time_s: float | None = None, max_output_tokens: int | None = None, ) -> PtyExecUpdate: + self._assert_mount_credentials_safe() async with self._pty_lock: entry = self._resolve_pty_session_entry( pty_processes=self._pty_sessions, @@ -1056,6 +1097,7 @@ def __init__( self._dependencies = dependencies self._token = token or os.environ.get("BL_API_KEY") + @_data_redacted_async_boundary async def create( self, *, @@ -1065,7 +1107,7 @@ async def create( ) -> SandboxSession: if manifest is None: manifest = Manifest(root=DEFAULT_BLAXEL_WORKSPACE_ROOT) - self._validate_manifest_mount_credentials(manifest) + self._validate_manifest_mount_credentials(manifest, options.env_vars) timeouts_in = options.timeouts if isinstance(timeouts_in, BlaxelTimeouts): @@ -1133,6 +1175,7 @@ async def delete(self, session: SandboxSession) -> SandboxSession: log_tool_action_warning(logger, "Shutdown failed during delete (non-fatal)", e) return session + @_data_redacted_async_boundary async def resume( self, state: SandboxSessionState, @@ -1146,6 +1189,8 @@ async def resume( """ if not isinstance(state, BlaxelSandboxSessionState): raise TypeError("BlaxelSandboxClient.resume expects a BlaxelSandboxSessionState") + self._validate_manifest_mount_credentials(state.manifest, state.base_env_vars) + state.assert_mount_credentials_safe() state.assert_trusted_manifest_rebound() SandboxInstance = _import_blaxel_sdk() @@ -1180,6 +1225,7 @@ async def resume( inner._skip_start = True # type: ignore[attr-defined] return self._wrap_session(inner, instrumentation=self._instrumentation) + @_data_redacted_boundary def deserialize_session_state(self, payload: dict[str, object]) -> SandboxSessionState: return self._deserialize_session_state_payload(payload, BlaxelSandboxSessionState) diff --git a/src/agents/extensions/sandbox/cloudflare/sandbox.py b/src/agents/extensions/sandbox/cloudflare/sandbox.py index cf221baf76..270d45cafe 100644 --- a/src/agents/extensions/sandbox/cloudflare/sandbox.py +++ b/src/agents/extensions/sandbox/cloudflare/sandbox.py @@ -30,6 +30,7 @@ import aiohttp from .... import _debug +from ....exceptions import _data_redacted_async_boundary, _data_redacted_boundary from ....logger import log_tool_action_debug from ....sandbox.errors import ( ConfigurationError, @@ -468,6 +469,7 @@ async def _resolve_exposed_port(self, port: int) -> ExposedPortEndpoint: }, ) + @_data_redacted_async_boundary async def mount_bucket( self, *, @@ -486,6 +488,7 @@ async def mount_bucket( "options": options, } + transport_error_type: str | None = None try: async with http.post( url, @@ -493,32 +496,33 @@ async def mount_bucket( timeout=self._request_timeout(), ) as resp: if resp.status != 200: - body: dict[str, Any] = {} - try: - body = await resp.json(content_type=None) - except Exception: - pass raise MountConfigError( message="cloudflare bucket mount failed", context={ "bucket": bucket, "mount_path": sandbox_path_str(workspace_path), "http_status": resp.status, - "reason": body.get("error", f"HTTP {resp.status}"), }, ) except MountConfigError: raise - except aiohttp.ClientError as e: + except asyncio.CancelledError: + raise + except Exception as e: + transport_error_type = type(e).__name__ + e.__traceback__ = None + e.__cause__ = None + e.__context__ = None + + if transport_error_type is not None: raise MountConfigError( message="cloudflare bucket mount failed", context={ "bucket": bucket, "mount_path": sandbox_path_str(workspace_path), - "cause_type": type(e).__name__, - "reason": str(e), + "cause_type": transport_error_type, }, - ) from e + ) async def unmount_bucket(self, mount_path: Path | str) -> None: workspace_path = await self._validate_path_access( @@ -1440,6 +1444,7 @@ def __init__( self._exec_timeout_s = exec_timeout_s self._request_timeout_s = request_timeout_s + @_data_redacted_async_boundary async def create( self, *, @@ -1502,6 +1507,7 @@ async def delete(self, session: SandboxSession) -> SandboxSession: await inner.shutdown() return session + @_data_redacted_async_boundary async def resume(self, state: SandboxSessionState) -> SandboxSession: if not isinstance(state, CloudflareSandboxSessionState): raise TypeError( @@ -1519,6 +1525,7 @@ async def resume(self, state: SandboxSessionState) -> SandboxSession: inner._set_start_state_preserved(reconnected) return self._wrap_session(inner, instrumentation=self._instrumentation) + @_data_redacted_boundary def deserialize_session_state(self, payload: dict[str, object]) -> SandboxSessionState: return self._deserialize_session_state_payload(payload, CloudflareSandboxSessionState) diff --git a/src/agents/extensions/sandbox/daytona/mounts.py b/src/agents/extensions/sandbox/daytona/mounts.py index 3bc5c1ddfa..9da3eeecbc 100644 --- a/src/agents/extensions/sandbox/daytona/mounts.py +++ b/src/agents/extensions/sandbox/daytona/mounts.py @@ -175,11 +175,12 @@ class DaytonaCloudBucketMountStrategy(MountStrategyBase): mount = S3Mount( bucket="my-bucket", - access_key_id="...", - secret_access_key="...", mount_path=Path("/mnt/bucket"), mount_strategy=DaytonaCloudBucketMountStrategy(), ) + + Direct credentials are rejected by default because this strategy runs the mount helper inside + the sandbox. Use a provider-controlled or external mount strategy for credentialed mounts. """ credential_boundary: ClassVar[Literal["inside_sandbox"]] = "inside_sandbox" diff --git a/src/agents/extensions/sandbox/daytona/sandbox.py b/src/agents/extensions/sandbox/daytona/sandbox.py index 9b84e78581..cb335648ec 100644 --- a/src/agents/extensions/sandbox/daytona/sandbox.py +++ b/src/agents/extensions/sandbox/daytona/sandbox.py @@ -26,6 +26,7 @@ from pydantic import BaseModel, Field +from ....exceptions import _data_redacted_async_boundary, _data_redacted_boundary from ....logger import log_tool_action_debug from ....sandbox.entries import Mount from ....sandbox.errors import ( @@ -1246,6 +1247,7 @@ async def _build_create_params( auto_stop_interval=auto_stop_interval, ) + @_data_redacted_async_boundary async def create( self, *, @@ -1255,7 +1257,7 @@ async def create( ) -> SandboxSession: if manifest is None: manifest = Manifest(root=DEFAULT_DAYTONA_WORKSPACE_ROOT) - self._validate_manifest_mount_credentials(manifest) + self._validate_manifest_mount_credentials(manifest, options.env_vars) timeouts_in = options.timeouts if isinstance(timeouts_in, DaytonaSandboxTimeouts): @@ -1321,12 +1323,14 @@ async def delete(self, session: SandboxSession) -> SandboxSession: pass return session + @_data_redacted_async_boundary async def resume( self, state: SandboxSessionState, ) -> SandboxSession: if not isinstance(state, DaytonaSandboxSessionState): raise TypeError("DaytonaSandboxClient.resume expects a DaytonaSandboxSessionState") + self._validate_manifest_mount_credentials(state.manifest, state.base_env_vars) state.assert_trusted_manifest_rebound() daytona_sandbox = None @@ -1358,6 +1362,7 @@ async def resume( inner._set_start_state_preserved(reconnected, system=reconnected) return self._wrap_session(inner, instrumentation=self._instrumentation) + @_data_redacted_boundary def deserialize_session_state(self, payload: dict[str, object]) -> SandboxSessionState: return self._deserialize_session_state_payload(payload, DaytonaSandboxSessionState) diff --git a/src/agents/extensions/sandbox/e2b/sandbox.py b/src/agents/extensions/sandbox/e2b/sandbox.py index a29f501d0d..2f8818c475 100644 --- a/src/agents/extensions/sandbox/e2b/sandbox.py +++ b/src/agents/extensions/sandbox/e2b/sandbox.py @@ -34,6 +34,7 @@ from pydantic import BaseModel, Field +from ....exceptions import _data_redacted_async_boundary, _data_redacted_boundary from ....logger import log_tool_action_warning from ....sandbox.entries import Mount from ....sandbox.errors import ( @@ -1399,12 +1400,14 @@ async def _persist_workspace_via_snapshot(self) -> io.IOBase: skip = self._persist_workspace_skip_relpaths() mount_targets = self.state.manifest.ephemeral_mount_targets() mount_skip_rel_paths: set[Path] = set() - for _mount_entry, mount_path in mount_targets: + detached_internal_rel_paths: set[Path] = set() + for mount_entry, mount_path in mount_targets: try: mount_skip_rel_paths.add(mount_path.relative_to(root)) except ValueError: continue - if skip - mount_skip_rel_paths: + detached_internal_rel_paths.update(mount_entry._native_snapshot_detach_cleanup_paths()) + if skip - mount_skip_rel_paths - detached_internal_rel_paths: return await self._persist_workspace_via_tar() unmounted_mounts: list[tuple[Mount, Path]] = [] @@ -1683,6 +1686,7 @@ def __init__( self._instrumentation = instrumentation or Instrumentation() self._dependencies = dependencies + @_data_redacted_async_boundary async def create( self, *, @@ -1693,7 +1697,7 @@ async def create( if options is None: raise ValueError("E2BSandboxClient.create requires options") manifest = manifest or Manifest() - self._validate_manifest_mount_credentials(manifest) + self._validate_manifest_mount_credentials(manifest, options.envs) sandbox_type = _coerce_sandbox_type(options.sandbox_type) @@ -1765,12 +1769,14 @@ async def delete(self, session: SandboxSession) -> SandboxSession: raise TypeError("E2BSandboxClient.delete expects an E2BSandboxSession") return session + @_data_redacted_async_boundary async def resume( self, state: SandboxSessionState, ) -> SandboxSession: if not isinstance(state, E2BSandboxSessionState): raise TypeError("E2BSandboxClient.resume expects an E2BSandboxSessionState") + self._validate_manifest_mount_credentials(state.manifest, state.base_envs) state.assert_trusted_manifest_rebound() sandbox_type = _coerce_sandbox_type(state.sandbox_type) @@ -1818,6 +1824,7 @@ async def resume( inner._set_start_state_preserved(reconnected, system=reconnected) return self._wrap_session(inner, instrumentation=self._instrumentation) + @_data_redacted_boundary def deserialize_session_state(self, payload: dict[str, object]) -> SandboxSessionState: return self._deserialize_session_state_payload(payload, E2BSandboxSessionState) diff --git a/src/agents/extensions/sandbox/modal/mounts.py b/src/agents/extensions/sandbox/modal/mounts.py index dc42dc5ee3..70f930c717 100644 --- a/src/agents/extensions/sandbox/modal/mounts.py +++ b/src/agents/extensions/sandbox/modal/mounts.py @@ -5,7 +5,10 @@ from typing import ClassVar, Literal from ....sandbox.entries import GCSMount, Mount, R2Mount, S3Mount -from ....sandbox.entries.mounts.base import MountStrategyBase +from ....sandbox.entries.mounts.base import ( + _RELEASED_UNIMPORTED_STRATEGY_CREDENTIAL_REFERENCE_FIELDS, + MountStrategyBase, +) from ....sandbox.errors import MountConfigError from ....sandbox.materialization import MaterializedFile from ....sandbox.session.base_sandbox_session import BaseSandboxSession @@ -26,6 +29,9 @@ class ModalCloudBucketMountConfig: class ModalCloudBucketMountStrategy(MountStrategyBase): credential_boundary: ClassVar[Literal["outside_sandbox"]] = "outside_sandbox" + _credential_reference_field_names: ClassVar[frozenset[str]] = ( + _RELEASED_UNIMPORTED_STRATEGY_CREDENTIAL_REFERENCE_FIELDS["modal_cloud_bucket"] + ) type: Literal["modal_cloud_bucket"] = "modal_cloud_bucket" secret_name: str | None = None secret_environment_name: str | None = None diff --git a/src/agents/extensions/sandbox/modal/sandbox.py b/src/agents/extensions/sandbox/modal/sandbox.py index cd64b54322..aba767b4dc 100644 --- a/src/agents/extensions/sandbox/modal/sandbox.py +++ b/src/agents/extensions/sandbox/modal/sandbox.py @@ -33,6 +33,7 @@ from modal.config import config as modal_config from modal.container_process import ContainerProcess +from ....exceptions import _data_redacted_async_boundary, _data_redacted_boundary from ....logger import log_tool_action_warning from ....sandbox.config import DEFAULT_PYTHON_SANDBOX_IMAGE from ....sandbox.entries import Mount @@ -701,24 +702,34 @@ async def _ensure_sandbox(self) -> bool: manifest_envs = cast(dict[str, str | None], await self.state.manifest.environment.resolve()) volumes = self._modal_cloud_bucket_mounts_for_manifest() - create_coro = modal.Sandbox.create.aio( - app=app, - image=self._image, - workdir=self.state.manifest.root, - env=manifest_envs, - encrypted_ports=self.state.exposed_ports, - volumes=volumes, - gpu=self.state.gpu, - timeout=self.state.timeout, - idle_timeout=self.state.idle_timeout, - ) - async with _override_modal_image_builder_version(self.state.image_builder_version): - if self.state.sandbox_create_timeout_s is None: - self._sandbox = await create_coro - else: - self._sandbox = await asyncio.wait_for( - create_coro, timeout=self.state.sandbox_create_timeout_s - ) + try: + create_coro = modal.Sandbox.create.aio( + app=app, + image=self._image, + workdir=self.state.manifest.root, + env=manifest_envs, + encrypted_ports=self.state.exposed_ports, + volumes=volumes, + gpu=self.state.gpu, + timeout=self.state.timeout, + idle_timeout=self.state.idle_timeout, + ) + async with _override_modal_image_builder_version(self.state.image_builder_version): + if self.state.sandbox_create_timeout_s is None: + self._sandbox = await create_coro + else: + self._sandbox = await asyncio.wait_for( + create_coro, timeout=self.state.sandbox_create_timeout_s + ) + except asyncio.CancelledError: + raise + except Exception: + if not volumes: + raise + raise MountConfigError( + message="failed to create Modal sandbox with cloud bucket mounts", + context={"mount_paths": sorted(str(path) for path in volumes)}, + ) from None # Persist sandbox id for future resume. assert self._sandbox is not None @@ -1620,9 +1631,7 @@ async def _refresh_sandbox_handle_for_snapshot(self) -> modal.Sandbox: return refreshed def _modal_snapshot_plain_skip_relpaths(self, root: Path) -> set[Path]: - plain_skip = set(self.state.manifest.ephemeral_entry_paths()) - if self._runtime_persist_workspace_skip_relpaths: - plain_skip.update(self._runtime_persist_workspace_skip_relpaths) + plain_skip = self._persist_workspace_skip_relpaths() mount_skip_rel_paths: set[Path] = set() for rel_path, artifact in self.state.manifest.iter_entries(): @@ -1912,22 +1921,28 @@ def _modal_cloud_bucket_mounts_for_manifest( strategy = mount_entry.mount_strategy if not isinstance(strategy, ModalCloudBucketMountStrategy): continue - config = strategy._build_modal_cloud_bucket_mount_config(mount_entry) - secret = None - if config.secret_name is not None: - secret = modal.Secret.from_name( - config.secret_name, - environment_name=config.secret_environment_name, + try: + config = strategy._build_modal_cloud_bucket_mount_config(mount_entry) + secret = None + if config.secret_name is not None: + secret = modal.Secret.from_name( + config.secret_name, + environment_name=config.secret_environment_name, + ) + elif config.credentials is not None: + secret = modal.Secret.from_dict(cast(dict[str, str | None], config.credentials)) + volumes[mount_path.as_posix()] = modal.CloudBucketMount( + bucket_name=config.bucket_name, + bucket_endpoint_url=config.bucket_endpoint_url, + key_prefix=config.key_prefix, + secret=secret, + read_only=config.read_only, ) - elif config.credentials is not None: - secret = modal.Secret.from_dict(cast(dict[str, str | None], config.credentials)) - volumes[mount_path.as_posix()] = modal.CloudBucketMount( - bucket_name=config.bucket_name, - bucket_endpoint_url=config.bucket_endpoint_url, - key_prefix=config.key_prefix, - secret=secret, - read_only=config.read_only, - ) + except Exception: + raise MountConfigError( + message="failed to configure Modal cloud bucket mount", + context={"mount_path": mount_path.as_posix()}, + ) from None return volumes @@ -1976,6 +1991,7 @@ def _validate_manifest_for_workspace_persistence( }, ) + @_data_redacted_async_boundary async def create( self, *, @@ -2172,6 +2188,7 @@ async def delete(self, session: SandboxSession) -> SandboxSession: return session + @_data_redacted_async_boundary async def resume( self, state: SandboxSessionState, @@ -2185,5 +2202,6 @@ async def resume( inner._set_start_state_preserved(True) return self._wrap_session(inner, instrumentation=self._instrumentation) + @_data_redacted_boundary def deserialize_session_state(self, payload: dict[str, object]) -> SandboxSessionState: return self._deserialize_session_state_payload(payload, ModalSandboxSessionState) diff --git a/src/agents/extensions/sandbox/runloop/sandbox.py b/src/agents/extensions/sandbox/runloop/sandbox.py index 52e0d967cb..a79f3e4ac9 100644 --- a/src/agents/extensions/sandbox/runloop/sandbox.py +++ b/src/agents/extensions/sandbox/runloop/sandbox.py @@ -34,6 +34,7 @@ UserParameters as _RunloopSdkUserParameters, ) +from ....exceptions import _data_redacted_async_boundary, _data_redacted_boundary from ....sandbox.entries import Mount from ....sandbox.errors import ( ExecTimeoutError, @@ -1552,6 +1553,7 @@ def __init__( def platform(self) -> RunloopPlatformClient: return self._platform + @_data_redacted_async_boundary async def create( self, *, @@ -1578,7 +1580,11 @@ async def create( user_parameters = _normalize_runloop_user_parameters(resolved_options.user_parameters) manifest = manifest or Manifest(root=_default_runloop_manifest_root(user_parameters)) - self._validate_manifest_mount_credentials(manifest) + option_environment_names = { + **dict(resolved_options.env_vars or {}), + **dict(resolved_options.managed_secrets or {}), + } + self._validate_manifest_mount_credentials(manifest, option_environment_names) _validate_runloop_manifest_root(manifest, user_parameters=user_parameters) timeouts_in = resolved_options.timeouts @@ -1661,6 +1667,7 @@ async def delete(self, session: SandboxSession) -> SandboxSession: pass return session + @_data_redacted_async_boundary async def resume( self, state: SandboxSessionState, @@ -1674,6 +1681,10 @@ async def resume( """ if not isinstance(state, RunloopSandboxSessionState): raise TypeError("RunloopSandboxClient.resume expects a RunloopSandboxSessionState") + self._validate_manifest_mount_credentials( + state.manifest, + {**state.base_env_vars, **state.secret_refs}, + ) state.assert_trusted_manifest_rebound() devbox = None @@ -1718,5 +1729,6 @@ async def resume( inner._set_start_state_preserved(reconnected, system=reconnected) return self._wrap_session(inner, instrumentation=self._instrumentation) + @_data_redacted_boundary def deserialize_session_state(self, payload: dict[str, object]) -> SandboxSessionState: return self._deserialize_session_state_payload(payload, RunloopSandboxSessionState) diff --git a/src/agents/extensions/sandbox/vercel/mounts.py b/src/agents/extensions/sandbox/vercel/mounts.py index 759b866795..6eef300aa9 100644 --- a/src/agents/extensions/sandbox/vercel/mounts.py +++ b/src/agents/extensions/sandbox/vercel/mounts.py @@ -477,7 +477,7 @@ class VercelCloudBucketMountStrategy(MountStrategyBase): Those exclusions keep the provider lifecycle auditable. """ - credential_boundary: ClassVar[Literal["client_opt_in"]] = "client_opt_in" + credential_boundary: ClassVar[Literal["inside_sandbox"]] = "inside_sandbox" type: Literal["vercel_cloud_bucket"] = "vercel_cloud_bucket" def validate_mount(self, mount: Mount) -> None: diff --git a/src/agents/extensions/sandbox/vercel/sandbox.py b/src/agents/extensions/sandbox/vercel/sandbox.py index f85ad6fd18..1b94611db9 100644 --- a/src/agents/extensions/sandbox/vercel/sandbox.py +++ b/src/agents/extensions/sandbox/vercel/sandbox.py @@ -28,6 +28,7 @@ from pydantic import TypeAdapter, field_serializer, field_validator from vercel import sandbox as vercel_sandbox +from ....exceptions import _data_redacted_async_boundary, _data_redacted_boundary from ....sandbox.entries import BaseEntry, Dir, S3Mount, resolve_workspace_path from ....sandbox.errors import ( ConfigurationError, @@ -328,18 +329,6 @@ def _manifest_without_vercel_s3_credentials(manifest: Manifest) -> Manifest: return sanitized -def _manifest_has_vercel_s3_credentials(manifest: Manifest) -> bool: - return any( - credential is not None - for mount in _vercel_s3_mounts(manifest) - for credential in ( - mount.access_key_id, - mount.secret_access_key, - mount.session_token, - ) - ) - - class VercelSandboxClientOptions(BaseSandboxClientOptions): """Client options for the Vercel sandbox backend.""" @@ -355,6 +344,7 @@ class VercelSandboxClientOptions(BaseSandboxClientOptions): workspace_persistence: WorkspacePersistenceMode = _WORKSPACE_PERSISTENCE_TAR snapshot_expiration_ms: int | None = None network_policy: NetworkPolicy | None = None + # Retained for constructor compatibility; credentials are always rejected before startup. allow_s3_credential_exposure: bool = False def __init__( @@ -497,12 +487,12 @@ def __init__( for credentials in trusted_s3_mount_credentials.values() for credential in credentials ) - if has_trusted_credentials and not allow_s3_credential_exposure: + _ = allow_s3_credential_exposure + if has_trusted_credentials: raise MountConfigError( message=( - "Vercel S3 mounts expose inline credentials to code running in the sandbox; " - "set allow_s3_credential_exposure=True only for credentials scoped to that " - "sandbox" + "Vercel S3 mounts cannot pass credentials to code running in the sandbox; " + "use an anonymous bucket or an external mount strategy" ), context={"backend": "vercel"}, ) @@ -1341,6 +1331,7 @@ def _wrap_session( dependencies=self._resolve_dependencies(), ) + @_data_redacted_async_boundary async def create( self, *, @@ -1350,18 +1341,6 @@ async def create( ) -> SandboxSession: resolved_manifest = _resolve_manifest_root(manifest) self._validate_manifest_mount_credentials(resolved_manifest) - if ( - _manifest_has_vercel_s3_credentials(resolved_manifest) - and not options.allow_s3_credential_exposure - ): - raise MountConfigError( - message=( - "Vercel S3 mounts expose inline credentials to code running in the sandbox; " - "set allow_s3_credential_exposure=True only for credentials scoped to that " - "sandbox" - ), - context={"backend": "vercel"}, - ) trusted_s3_mounts = _vercel_s3_mount_map(resolved_manifest) for mount in trusted_s3_mounts.values(): mount.mount_strategy.validate_mount(mount) @@ -1412,6 +1391,7 @@ async def delete(self, session: SandboxSession) -> SandboxSession: pass return session + @_data_redacted_async_boundary async def resume(self, state: SandboxSessionState) -> SandboxSession: if not isinstance(state, VercelSandboxSessionState): raise TypeError("VercelSandboxClient.resume expects a VercelSandboxSessionState") @@ -1483,6 +1463,7 @@ async def resume(self, state: SandboxSessionState) -> SandboxSession: inner._set_start_state_preserved(reconnected) return self._wrap_session(inner, instrumentation=self._instrumentation) + @_data_redacted_boundary def deserialize_session_state(self, payload: dict[str, object]) -> SandboxSessionState: return self._deserialize_session_state_payload(payload, VercelSandboxSessionState) diff --git a/src/agents/run_internal/error_handlers.py b/src/agents/run_internal/error_handlers.py index f55e8b9929..26678cf68e 100644 --- a/src/agents/run_internal/error_handlers.py +++ b/src/agents/run_internal/error_handlers.py @@ -15,6 +15,7 @@ ModelRefusalError, OutputGuardrailTripwireTriggered, UserError, + _is_error_data_redacted, ) from ..items import ( ItemHelpers, @@ -93,7 +94,7 @@ def attach_generic_agent_error( return detail = ( _format_agent_error_detail(exc) - if trace_include_sensitive_data + if trace_include_sensitive_data and not _is_error_data_redacted(exc) else REDACTED_TRACE_ERROR_MESSAGE ) _error_tracing.attach_error_to_span( diff --git a/src/agents/run_state.py b/src/agents/run_state.py index e56726d920..aa8dbea22b 100644 --- a/src/agents/run_state.py +++ b/src/agents/run_state.py @@ -51,7 +51,7 @@ serialize_function_tool_lookup_key, ) from .agent import Agent -from .exceptions import UserError +from .exceptions import UserError, _data_redacted_async_boundary, _data_redacted_boundary from .guardrail import ( GuardrailFunctionOutput, InputGuardrail, @@ -775,6 +775,7 @@ def _id_type_call(item: Any) -> tuple[str | None, str | None, str | None]: self._generated_items_last_processed_marker = current_merge_marker return generated_items + @_data_redacted_boundary def to_json( self, *, @@ -1097,6 +1098,7 @@ def _extract_name(raw: Any) -> str | None: return "" + @_data_redacted_boundary def to_string( self, *, @@ -1155,6 +1157,7 @@ def get_tool_use_tracker_snapshot(self) -> dict[str, list[str]]: } @staticmethod + @_data_redacted_async_boundary async def from_string( initial_agent: Agent[Any], state_string: str, @@ -1196,6 +1199,7 @@ async def from_string( ) @staticmethod + @_data_redacted_async_boundary async def from_json( initial_agent: Agent[Any], state_json: dict[str, Any], diff --git a/src/agents/sandbox/_mount_security.py b/src/agents/sandbox/_mount_security.py index f7b3cc538a..1b6fe36b43 100644 --- a/src/agents/sandbox/_mount_security.py +++ b/src/agents/sandbox/_mount_security.py @@ -1,17 +1,71 @@ from __future__ import annotations import copy -from collections.abc import Mapping +from collections.abc import Iterable, Mapping from pathlib import Path from typing import Any, cast from .entries import Mount from .entries.base import BaseEntry +from .entries.mounts.base import ( + _RELEASED_UNIMPORTED_MOUNT_SAFE_FIELDS, + _RELEASED_UNIMPORTED_STRATEGY_CREDENTIAL_REFERENCE_FIELDS, + _RELEASED_UNIMPORTED_STRATEGY_SAFE_FIELDS, + MountStrategyBase, + _configured_option_map_credential_fields, + _field_contains_inline_url_credentials, + _is_credential_file_driver_option_name, + _is_generic_credential_name, + _is_rclone_opaque_credential_authority_option, + _matches_credential_field_name, + _normalize_driver_option_name, + _rclone_driver_option_credential_kind, + _rclone_extra_args_use_credential_source, +) from .errors import InvalidManifestPathError, MountConfigError from .manifest import Manifest from .workspace_paths import coerce_posix_path, posix_path_as_path REDACTED_MOUNT_CREDENTIAL_PATHS_KEY = "__openai_agents_redacted_mount_credential_paths" +REDACTED_HOST_PATH_GRANT_PATHS_KEY = "__openai_agents_redacted_host_path_grant_paths" +_DRIVER_OPTIONS_CREDENTIAL_SLOT = "mount_strategy.driver_options" +_RAW_MOUNT_CREDENTIAL_SLOT = "mount.raw_credential" +_RCLONE_CONFIG_CREDENTIAL_SLOT = "mount_strategy.pattern.config_file_path" +_STRATEGY_CREDENTIAL_SLOT = "mount_strategy.credential" +_RAW_UNREGISTERED_MOUNT_SAFE_FIELDS = frozenset( + { + "mount_strategy", + "type", + } +) +_RELEASED_UNIMPORTED_MOUNT_BASE_SAFE_FIELDS = frozenset( + { + "description", + "ephemeral", + "group", + "is_dir", + "mount_path", + "permissions", + "read_only", + } +) +_RAW_UNREGISTERED_MOUNT_STRATEGY_SAFE_FIELDS = frozenset({"type"}) +_SDK_DIRECT_MOUNT_CREDENTIAL_SLOTS = frozenset( + { + "access_id", + "access_key_id", + "access_token", + "account_key", + "box_config_file", + "client_secret", + "config_credentials", + "secret_access_key", + "service_account_credentials", + "service_account_file", + "session_token", + "token", + } +) _RUN_STATE_SANDBOX_FIELDS = frozenset( { "backend_id", @@ -22,24 +76,333 @@ "sessions_by_agent", } ) +_RELEASED_IN_CONTAINER_MOUNT_STRATEGY_TYPES = frozenset( + { + "blaxel_cloud_bucket", + "daytona_cloud_bucket", + "e2b_cloud_bucket", + "in_container", + "runloop_cloud_bucket", + } +) +_CREDENTIAL_ENVIRONMENT_NAMES = frozenset( + { + "AWS_CONFIG_FILE", + "AWS_PROFILE", + "AWS_SHARED_CREDENTIALS_FILE", + "AZURE_STORAGE_CONNECTION_STRING", + "GOOGLE_APPLICATION_CREDENTIALS", + "RCLONE_CONFIG", + "RCLONE_CONFIG_PASS", + } +) + + +def _credential_like_environment_names(names: object) -> tuple[str, ...]: + if isinstance(names, Mapping): + candidates = names + elif isinstance(names, Iterable) and not isinstance(names, str | bytes): + candidates = names + else: + return () + return tuple( + sorted( + name + for name in candidates + if isinstance(name, str) + and (name.upper() in _CREDENTIAL_ENVIRONMENT_NAMES or _is_generic_credential_name(name)) + ) + ) + + +def validate_mount_credential_environment_names( + manifest: Manifest, + environment_names: object, +) -> None: + """Reject ambient credential sources when a helper runs inside the sandbox.""" + + in_container_mount_paths = tuple( + mount_path.as_posix() + for mount, mount_path in manifest.mount_targets() + if mount.mount_strategy.credential_boundary == "inside_sandbox" + ) + credential_environment_names = _credential_like_environment_names(environment_names) + if not in_container_mount_paths or not credential_environment_names: + return + raise MountConfigError( + message=( + "credential-like environment variables cannot be used with in-container mounts; " + "use an anonymous endpoint or an external or provider-controlled strategy" + ), + context={ + "mount_paths": list(in_container_mount_paths), + "credential_environment_names": list(credential_environment_names), + }, + ) + + +def validate_mount_credential_boundary_for_session( + mount: Mount, + manifest: Manifest, + environment_names: object, +) -> None: + """Apply the shared mount boundary to a direct mount lifecycle call.""" + + isolated_manifest = Manifest( + root=manifest.root, + entries={"mount": mount}, + environment=manifest.environment.model_copy(deep=True), + ) + validate_mount_credential_boundaries(isolated_manifest) + validate_mount_credential_environment_names(isolated_manifest, environment_names) + + +def _configured_pattern_credential_fields(pattern: object) -> tuple[str, ...]: + """Return unsupported credential carriers configured directly on a mount pattern.""" + + if isinstance(pattern, Mapping): + pattern_type = pattern.get("type") + remote_name = pattern.get("remote_name") + options = pattern.get("options") + else: + pattern_type = getattr(pattern, "type", None) + remote_name = getattr(pattern, "remote_name", None) + options = getattr(pattern, "options", None) + + configured: list[str] = [] + if pattern_type == "rclone" and isinstance(remote_name, str) and remote_name.startswith(":"): + configured.append("remote_name") + + endpoint_url = ( + options.get("endpoint_url") + if isinstance(options, Mapping) + else getattr(options, "endpoint_url", None) + ) + if pattern_type == "mountpoint" and _field_contains_inline_url_credentials( + "endpoint_url", + endpoint_url, + url_field_names=frozenset({"endpoint_url"}), + ): + configured.append("options.endpoint_url") + + extra_options = ( + options.get("extra_options") + if isinstance(options, Mapping) + else getattr(options, "extra_options", None) + ) + if pattern_type == "s3files": + if extra_options is not None and not isinstance(extra_options, Mapping): + configured.append("options.extra_options") + elif isinstance(extra_options, Mapping): + configured.extend( + _configured_option_map_credential_fields( + extra_options, + field_prefix="options.extra_options", + ) + ) + + return tuple(configured) + + +def _raw_entries_use_in_container_mount(entries: Mapping[object, object]) -> bool: + stack = [entries] + while stack: + current = stack.pop() + for raw_entry in current.values(): + if not isinstance(raw_entry, Mapping): + continue + if _raw_mount_uses_in_container_strategy(raw_entry): + return True + children = raw_entry.get("children") + if isinstance(children, Mapping): + stack.append(children) + return False + + +def _raw_mount_uses_in_container_strategy(raw_entry: Mapping[object, object]) -> bool: + strategy = raw_entry.get("mount_strategy") + if not isinstance(strategy, Mapping): + return False + strategy_type = strategy.get("type") + strategy_class = ( + MountStrategyBase._subclass_registry.get(strategy_type) + if isinstance(strategy_type, str) + else None + ) + return bool( + strategy_type in _RELEASED_IN_CONTAINER_MOUNT_STRATEGY_TYPES + or strategy_class is not None + and strategy_class.credential_boundary == "inside_sandbox" + ) def validate_mount_credential_boundaries(manifest: Manifest) -> None: """Reject credential-bearing mounts that can expose credentials inside the sandbox.""" + manifest._reject_serialized_files_with_opaque_credential_authorities() + validate_mount_credential_environment_names(manifest, manifest.environment.value) for mount, mount_path in manifest.mount_targets(): + inline_url_credential_fields = tuple( + dict.fromkeys( + ( + *mount._configured_inline_url_credential_fields(), + *( + f"mount_strategy.{field_name}" + for field_name in ( + mount.mount_strategy._configured_inline_url_credential_fields() + ) + ), + ) + ) + ) + if inline_url_credential_fields: + raise MountConfigError( + message=( + "mount endpoint URLs cannot contain inline credentials; use typed mount " + "credentials with an external or provider-controlled strategy" + ), + context={ + "mount_path": mount_path.as_posix(), + "mount_type": mount.type, + "mount_strategy": mount.mount_strategy.type, + "credential_fields": list(inline_url_credential_fields), + }, + ) + undeclared_mount_fields = mount._configured_undeclared_credential_fields() + if undeclared_mount_fields: + raise MountConfigError( + message=( + "mount credential fields must be declared by the mount implementation " + "before use" + ), + context={ + "mount_path": mount_path.as_posix(), + "mount_type": mount.type, + "mount_strategy": mount.mount_strategy.type, + "credential_fields": list(undeclared_mount_fields), + }, + ) + undeclared_strategy_fields = mount.mount_strategy._configured_undeclared_credential_fields() + if undeclared_strategy_fields: + raise MountConfigError( + message=( + "mount strategy credential fields must be declared by the strategy " + "implementation before use" + ), + context={ + "mount_path": mount_path.as_posix(), + "mount_type": mount.type, + "mount_strategy": mount.mount_strategy.type, + "credential_fields": list(undeclared_strategy_fields), + }, + ) + ambient_reference_fields = mount._configured_ambient_credential_reference_fields() + if ( + ambient_reference_fields + and mount.mount_strategy.credential_boundary == "inside_sandbox" + ): + raise MountConfigError( + message=( + "in-container mounts cannot select an ambient cloud identity; use an " + "external or provider-controlled strategy" + ), + context={ + "mount_path": mount_path.as_posix(), + "mount_type": mount.type, + "mount_strategy": mount.mount_strategy.type, + "credential_fields": list(ambient_reference_fields), + }, + ) + unsafe_driver_options = mount._configured_unsafe_driver_options() + if unsafe_driver_options: + raise MountConfigError( + message=( + "rclone Docker driver options cannot enable credential-revealing output or " + "remote-control services" + ), + context={ + "mount_path": mount_path.as_posix(), + "mount_type": mount.type, + "mount_strategy": mount.mount_strategy.type, + "driver_options": list(unsafe_driver_options), + }, + ) + if mount._configured_parameterized_connection_string_options(): + raise MountConfigError( + message=( + "rclone Docker connection strings cannot contain inline parameters; use " + "flat type, path, and backend driver options" + ), + context={ + "mount_path": mount_path.as_posix(), + "mount_type": mount.type, + "mount_strategy": mount.mount_strategy.type, + }, + ) + configured_pattern_credential_fields = _configured_pattern_credential_fields( + getattr(mount.mount_strategy, "pattern", None) + ) + if configured_pattern_credential_fields: + raise MountConfigError( + message=( + "mount patterns cannot contain inline credential configuration; use typed " + "mount credentials with an external or provider-controlled strategy" + ), + context={ + "mount_path": mount_path.as_posix(), + "mount_type": mount.type, + "mount_strategy": mount.mount_strategy.type, + "credential_fields": list(configured_pattern_credential_fields), + }, + ) + pattern = mount._rclone_pattern() + if pattern is not None and _rclone_extra_args_use_credential_source(pattern.extra_args): + raise MountConfigError( + message=( + "rclone extra_args cannot configure or expose credentials or select a config " + "file; use typed mount credentials or config_file_path" + ), + context={ + "mount_path": mount_path.as_posix(), + "mount_type": mount.type, + "mount_strategy": mount.mount_strategy.type, + }, + ) + credential_fields = mount._configured_credential_fields() + pattern_type = getattr(getattr(mount.mount_strategy, "pattern", None), "type", None) + direct_credential_fields = ( + mount._credential_field_names - mount._credential_file_field_names + ) + has_direct_credentials = any( + getattr(mount, field_name, None) is not None for field_name in direct_credential_fields + ) + if ( + mount.mount_strategy.credential_boundary == "inside_sandbox" + and pattern_type in {"fuse", "mountpoint"} + and not has_direct_credentials + ): + raise MountConfigError( + message=( + "this in-container mount pattern requires ambient credentials when direct " + "credentials are absent; use an anonymous rclone mount or an external or " + "provider-controlled strategy" + ), + context={ + "mount_path": mount_path.as_posix(), + "mount_type": mount.type, + "mount_strategy": mount.mount_strategy.type, + "mount_pattern": pattern_type, + }, + ) if not credential_fields: continue - _reject_serialized_rclone_config_source(manifest, mount, mount_path) + _reject_serialized_credential_file_sources(manifest, mount, mount_path) boundary = mount.mount_strategy.credential_boundary - if boundary in {"outside_sandbox", "client_opt_in"}: - continue - if manifest._allows_in_container_mount_credential_exposure(mount_path): + if boundary == "outside_sandbox": continue - context = { "mount_path": mount_path.as_posix(), "mount_type": mount.type, @@ -50,8 +413,8 @@ def validate_mount_credential_boundaries(manifest: Manifest) -> None: raise MountConfigError( message=( "mount credentials cannot be passed to a helper inside a model-controlled " - "sandbox by default; use an external or provider-native mount strategy, or " - "explicitly acknowledge exposure on a trusted Manifest instance" + "sandbox; use an anonymous endpoint or an external or provider-native mount " + "strategy" ), context=context, ) @@ -64,31 +427,73 @@ def validate_mount_credential_boundaries(manifest: Manifest) -> None: ) -def redact_manifest_mount_credentials(manifest: Manifest) -> tuple[Manifest, tuple[str, ...]]: +def _validate_released_unimported_mount_base_field(field_name: str, value: object) -> None: + """Validate the fixed base-Mount projection shipped in released serialized state.""" + + if field_name in {"description", "mount_path"}: + if value is None or isinstance(value, str): + return + elif field_name in {"ephemeral", "is_dir", "read_only"}: + if type(value) is bool: + return + elif field_name == "group": + if value is None: + return + if isinstance(value, dict) and isinstance(value.get("name"), str): + if set(value) == {"name"}: + return + users = value.get("users") + if ( + set(value) == {"name", "users"} + and isinstance(users, list) + and all( + isinstance(user, dict) + and set(user) == {"name"} + and isinstance(user.get("name"), str) + for user in users + ) + ): + return + elif field_name == "permissions" and isinstance(value, dict): + if ( + set(value) == {"owner", "group", "other", "directory"} + and all( + type(value[key]) is int and 0 <= value[key] <= 7 + for key in ("owner", "group", "other") + ) + and type(value["directory"]) is bool + ): + return + raise ValueError("Persisted unregistered mount base field has an invalid shape") + + +def redact_manifest_mount_credentials( + manifest: Manifest, +) -> tuple[Manifest, dict[str, tuple[str, ...]]]: """Return a manifest copy without credentials and stable entry paths that need rebind.""" - paths = tuple( - entry_path + manifest._reject_serialized_files_with_opaque_credential_authorities() + credentials_by_path = { + entry_path: _canonical_credential_slots(mount._configured_credential_fields()) for entry_path, mount in _mounts_by_entry_path(manifest).items() if mount._configured_credential_fields() - ) - if not paths: - return manifest, () + } + if not credentials_by_path: + return manifest, {} redacted = manifest.model_copy(deep=True) redacted_mounts = _mounts_by_entry_path(redacted) - for path in paths: + for path in credentials_by_path: mount = redacted_mounts.get(path) if mount is None: raise ValueError(f"Could not redact persisted mount credentials for {path}") mount._clear_configured_credentials() - redacted._in_container_mount_credential_exposure_allowed_paths = frozenset() - return redacted, tuple(dict.fromkeys(paths)) + return redacted, credentials_by_path def sanitize_serialized_mount_credentials( payload: dict[str, object], -) -> tuple[dict[str, object], tuple[str, ...]]: +) -> tuple[dict[str, object], dict[str, tuple[str, ...]]]: """Strip mount credentials from a raw state payload before model validation.""" sanitized = dict(payload) @@ -96,7 +501,7 @@ def sanitize_serialized_mount_credentials( if "manifest" in sanitized and not isinstance(raw_manifest, dict): raise ValueError("Persisted sandbox manifest must be an object") if not isinstance(raw_manifest, dict): - return sanitized, () + return sanitized, {} if { "in_container_mount_credential_exposure_allowed_paths", "_in_container_mount_credential_exposure_allowed_paths", @@ -104,28 +509,74 @@ def sanitize_serialized_mount_credentials( raise ValueError( "Persisted sandbox manifest cannot configure mount credential exposure policy" ) - manifest = dict(raw_manifest) + _validate_raw_manifest_shape(raw_manifest) + manifest = {key: value for key, value in raw_manifest.items() if key in Manifest.model_fields} sanitized["manifest"] = manifest raw_entries = manifest.get("entries") if "entries" in manifest and not isinstance(raw_entries, dict): raise ValueError("Persisted sandbox manifest entries must be an object") if not isinstance(raw_entries, dict): - return sanitized, () + return sanitized, {} + raw_environment = manifest.get("environment") + raw_environment_values = ( + raw_environment.get("value") if isinstance(raw_environment, dict) else None + ) + serialized_environment_names: dict[object, object] = {} + if isinstance(raw_environment_values, Mapping): + serialized_environment_names.update(raw_environment_values) + for field_name in ("base_envs", "base_env_vars", "secret_refs"): + field_value = sanitized.get(field_name) + if isinstance(field_value, Mapping): + serialized_environment_names.update(field_value) + credential_environment_names = _credential_like_environment_names(serialized_environment_names) + if credential_environment_names and _raw_entries_use_in_container_mount(raw_entries): + raise ValueError( + "Persisted credential-like manifest environment variables cannot be used with " + "in-container mounts" + ) entries = copy.deepcopy(raw_entries) manifest["entries"] = entries entry_paths = _collect_raw_entry_paths(entries) + has_serialized_files = _raw_entries_contain_serialized_file(entries) raw_root = manifest.get("root", "/workspace") root = Path(raw_root) if isinstance(raw_root, str) else Path("/workspace") - credential_paths: list[str] = [] + credentials_by_path: dict[str, list[str]] = {} _sanitize_entry_mapping( entries, prefix="", - credential_paths=credential_paths, + credentials_by_path=credentials_by_path, entry_paths=entry_paths, + has_serialized_files=has_serialized_files, root=root, ) - return sanitized, tuple(dict.fromkeys(credential_paths)) + return sanitized, { + path: _canonical_credential_slots(tuple(sorted(set(field_names)))) + for path, field_names in credentials_by_path.items() + } + + +def _validate_raw_manifest_shape(raw_manifest: dict[str, object]) -> None: + expected_types: dict[str, type[object] | tuple[type[object], ...]] = { + "version": int, + "root": str, + "entries": dict, + "environment": dict, + "users": list, + "groups": list, + "extra_path_grants": (list, tuple), + "remote_mount_command_allowlist": list, + } + for field_name, expected_type in expected_types.items(): + if field_name not in raw_manifest: + continue + value = raw_manifest[field_name] + if field_name == "version": + valid = type(value) is int and value == 1 + else: + valid = isinstance(value, expected_type) + if not valid: + raise ValueError(f"Persisted sandbox manifest {field_name} has an invalid shape") def sanitize_serialized_session_state_mount_credentials( @@ -133,25 +584,53 @@ def sanitize_serialized_session_state_mount_credentials( ) -> dict[str, object]: """Strip mount credentials and preserve the trusted-rebind marker.""" - sanitized_payload, mount_paths = sanitize_serialized_mount_credentials(payload) + _validate_canonical_structural_key_location( + payload, + canonical_key=REDACTED_MOUNT_CREDENTIAL_PATHS_KEY, + allowed_path=(REDACTED_MOUNT_CREDENTIAL_PATHS_KEY,), + invalid_message="Persisted sandbox mount credential marker has an invalid field name", + ambiguous_message="Persisted sandbox mount credential marker has an ambiguous location", + ) + _validate_canonical_structural_key_location( + payload, + canonical_key="manifest", + allowed_path=("manifest",), + invalid_message="Persisted sandbox session_state has an invalid manifest field name", + ambiguous_message="Persisted sandbox session_state has an ambiguous shape", + ) + + sanitized_payload, credentials_by_path = sanitize_serialized_mount_credentials(payload) marker_present = REDACTED_MOUNT_CREDENTIAL_PATHS_KEY in sanitized_payload - if not mount_paths and not marker_present: + if not credentials_by_path and not marker_present: return sanitized_payload existing_marker = sanitized_payload.get(REDACTED_MOUNT_CREDENTIAL_PATHS_KEY) - if marker_present and not isinstance(existing_marker, list): - raise ValueError("Persisted sandbox mount credential marker must be a list") - if isinstance(existing_marker, list) and not all( - isinstance(path, str) for path in existing_marker - ): - raise ValueError("Persisted sandbox mount credential marker must contain strings") - existing_paths = cast(list[str], existing_marker) if isinstance(existing_marker, list) else [] + if marker_present and not isinstance(existing_marker, dict): + raise ValueError("Persisted sandbox mount credential marker must be an object") + existing_credentials: dict[str, tuple[str, ...]] = {} + if isinstance(existing_marker, dict): + valid_slots_by_path = _raw_mount_credential_slots_by_path(sanitized_payload) + for path, field_names in existing_marker.items(): + if ( + not isinstance(path, str) + or not isinstance(field_names, list) + or not all(isinstance(field_name, str) for field_name in field_names) + ): + raise ValueError("Persisted sandbox mount credential marker has an invalid shape") + normalized_fields = tuple(sorted(set(cast(list[str], field_names)))) + valid_slots = valid_slots_by_path.get(path) + if valid_slots is None or not set(normalized_fields) <= valid_slots: + raise ValueError("Persisted sandbox mount credential marker is invalid") + existing_credentials[path] = normalized_fields valid_mount_paths = _raw_mount_entry_paths(sanitized_payload) - if any(path not in valid_mount_paths for path in (*existing_paths, *mount_paths)): + if any(path not in valid_mount_paths for path in credentials_by_path): raise ValueError("Persisted sandbox mount credential marker contains an invalid path") - sanitized_payload[REDACTED_MOUNT_CREDENTIAL_PATHS_KEY] = list( - dict.fromkeys((*existing_paths, *mount_paths)) - ) + merged_credentials = dict(existing_credentials) + for path, field_names in credentials_by_path.items(): + merged_credentials[path] = tuple(sorted({*merged_credentials.get(path, ()), *field_names})) + sanitized_payload[REDACTED_MOUNT_CREDENTIAL_PATHS_KEY] = { + path: list(field_names) for path, field_names in merged_credentials.items() + } return sanitized_payload @@ -201,45 +680,70 @@ def sanitize_run_state_sandbox_mount_credentials( def _sanitize_run_state_session_state(payload: dict[str, object]) -> dict[str, object]: - from .session.sandbox_session_state import ( - REDACTED_HOST_PATH_GRANT_PATHS_KEY, - SandboxSessionState, - ) - state_type = payload.get("type") if not isinstance(state_type, str): raise ValueError("RunState sandbox session_state must include a string type") - state_class = SandboxSessionState._subclass_registry.get(state_type) - if state_class is None: - raise ValueError("RunState sandbox session_state has an unknown type") - allowed_fields = { - *state_class.model_fields, - REDACTED_HOST_PATH_GRANT_PATHS_KEY, - REDACTED_MOUNT_CREDENTIAL_PATHS_KEY, - } - if set(payload) - allowed_fields: - raise ValueError("RunState sandbox session_state has an invalid shape") - - state = SandboxSessionState.parse(payload) - normalized = cast(dict[str, object], state.model_dump(mode="json")) - if state.path_grants_require_rebind: - normalized[REDACTED_HOST_PATH_GRANT_PATHS_KEY] = list(state.path_grants_require_rebind) - if state.mount_credentials_require_rebind: - normalized[REDACTED_MOUNT_CREDENTIAL_PATHS_KEY] = list( - state.mount_credentials_require_rebind + + sanitized = sanitize_serialized_session_state_mount_credentials(payload) + return _sanitize_serialized_host_path_grants(sanitized) + + +def _sanitize_serialized_host_path_grants(payload: dict[str, object]) -> dict[str, object]: + sanitized = dict(payload) + raw_manifest = sanitized.get("manifest") + if not isinstance(raw_manifest, dict): + return sanitized + + manifest = dict(raw_manifest) + sanitized["manifest"] = manifest + raw_grants = manifest.get("extra_path_grants") + if "extra_path_grants" in manifest and not isinstance(raw_grants, list | tuple): + raise ValueError("Persisted sandbox extra_path_grants must be a list") + + redacted_paths: list[str] = [] + persistent_grants: list[object] = [] + if isinstance(raw_grants, list | tuple): + for grant in raw_grants: + if not isinstance(grant, dict): + raise ValueError("Persisted sandbox path grants must be objects") + if grant.get("host_path") is None: + persistent_grants.append(copy.deepcopy(grant)) + continue + grant_path = grant.get("path") + if not isinstance(grant_path, str): + raise ValueError("Persisted sandbox host path grants must include a string path") + redacted_paths.append(grant_path) + manifest["extra_path_grants"] = persistent_grants + + marker_present = REDACTED_HOST_PATH_GRANT_PATHS_KEY in sanitized + marker = sanitized.get(REDACTED_HOST_PATH_GRANT_PATHS_KEY) + if marker_present and not isinstance(marker, list): + raise ValueError("Persisted sandbox host path grant marker must be a list") + if isinstance(marker, list) and not all(isinstance(path, str) for path in marker): + raise ValueError("Persisted sandbox host path grant marker must contain strings") + existing_paths = cast(list[str], marker) if isinstance(marker, list) else [] + if existing_paths or redacted_paths: + sanitized[REDACTED_HOST_PATH_GRANT_PATHS_KEY] = list( + dict.fromkeys((*existing_paths, *redacted_paths)) ) - return normalized + return sanitized def _raw_mount_entry_paths(payload: dict[str, object]) -> frozenset[str]: + return frozenset(_raw_mount_credential_slots_by_path(payload)) + + +def _raw_mount_credential_slots_by_path( + payload: dict[str, object], +) -> dict[str, frozenset[str]]: manifest = payload.get("manifest") if not isinstance(manifest, dict): - return frozenset() + return {} entries = manifest.get("entries") if not isinstance(entries, dict): - return frozenset() + return {} registered_types = BaseEntry.registered_types() - mount_paths: set[str] = set() + slots_by_path: dict[str, frozenset[str]] = {} stack: list[tuple[str, dict[Any, Any]]] = [("", entries)] while stack: prefix, current_entries = stack.pop() @@ -250,28 +754,57 @@ def _raw_mount_entry_paths(payload: dict[str, object]) -> frozenset[str]: entry_path = f"{prefix}/{name}" if prefix else name entry_type = raw_entry.get("type") entry_class = registered_types.get(entry_type) if isinstance(entry_type, str) else None - if entry_class is not None and issubclass(entry_class, Mount): - mount_paths.add(entry_path) + mount_class = ( + entry_class if entry_class is not None and issubclass(entry_class, Mount) else None + ) + if mount_class is not None or "mount_strategy" in raw_entry: + slots = {_RAW_MOUNT_CREDENTIAL_SLOT} + if mount_class is not None: + slots.update( + mount_class._credential_field_names & _SDK_DIRECT_MOUNT_CREDENTIAL_SLOTS + ) + strategy = raw_entry.get("mount_strategy") + if isinstance(strategy, dict): + strategy_type = strategy.get("type") + strategy_class = ( + MountStrategyBase._subclass_registry.get(strategy_type) + if isinstance(strategy_type, str) + else None + ) + if strategy_class is not None and strategy_class._credential_field_names: + slots.add(_STRATEGY_CREDENTIAL_SLOT) + if isinstance(strategy.get("driver_options"), dict): + slots.add(_DRIVER_OPTIONS_CREDENTIAL_SLOT) + pattern = strategy.get("pattern") + if isinstance(pattern, dict): + slots.add(_RCLONE_CONFIG_CREDENTIAL_SLOT) + slots_by_path[entry_path] = frozenset(slots) children = raw_entry.get("children") if isinstance(children, dict): stack.append((entry_path, children)) - return frozenset(mount_paths) + return slots_by_path def rebind_manifest_mount_credentials( manifest: Manifest, trusted_manifest: Manifest | None, - mount_paths: tuple[str, ...], + credentials_by_path: dict[str, tuple[str, ...]], + *, + allow_runtime_root_mismatch: bool = False, ) -> Manifest: """Restore credentials only when persisted and trusted mount topology is identical.""" - if not mount_paths: + if not credentials_by_path: return manifest if trusted_manifest is None: raise ValueError( "Sandbox session state contains mount credentials that require a current trusted " "manifest before resume" ) + if manifest.root != trusted_manifest.root and not allow_runtime_root_mismatch: + raise ValueError( + "Sandbox session state manifest root does not match current trusted configuration" + ) rebound = manifest.model_copy(deep=True) rebound_mounts = _mounts_by_entry_path(rebound) @@ -294,9 +827,8 @@ def rebind_manifest_mount_credentials( f"configuration for these paths: {', '.join(mismatched_paths)}" ) - for path in mount_paths: - rebound_mounts[path]._restore_configured_credentials(trusted_mounts[path]) - rebound._copy_mount_credential_exposure_policy_from(trusted_manifest) + for path, field_names in credentials_by_path.items(): + rebound_mounts[path]._restore_configured_credentials(trusted_mounts[path], field_names) return rebound @@ -304,8 +836,9 @@ def _sanitize_entry_mapping( entries: dict[Any, Any], *, prefix: str, - credential_paths: list[str], + credentials_by_path: dict[str, list[str]], entry_paths: frozenset[str], + has_serialized_files: bool, root: Path, ) -> None: registered_types = BaseEntry.registered_types() @@ -315,6 +848,18 @@ def _sanitize_entry_mapping( if issubclass(entry_class, Mount) for field_name in entry_class._credential_field_names } + all_credential_file_fields = { + field_name + for entry_class in registered_types.values() + if issubclass(entry_class, Mount) + for field_name in entry_class._credential_file_field_names + } + all_credential_driver_option_names = { + option_name + for entry_class in registered_types.values() + if issubclass(entry_class, Mount) + for option_name in entry_class._credential_driver_option_names + } for raw_name, raw_entry in entries.items(): if not isinstance(raw_entry, dict): @@ -326,34 +871,390 @@ def _sanitize_entry_mapping( mount_class: type[Mount] | None = ( entry_class if entry_class is not None and issubclass(entry_class, Mount) else None ) + _reject_noncanonical_mapping_key( + raw_entry, + canonical_key="mount_strategy", + message="Persisted sandbox mount strategy has an invalid field name", + ) looks_like_mount = ( - mount_class is not None or entry_class is None or "mount_strategy" in raw_entry + mount_class is not None or entry_type is None or "mount_strategy" in raw_entry ) + if ( + entry_class is None + and isinstance(entry_type, str) + and "mount_strategy" not in raw_entry + and any( + isinstance(field_name, str) and _is_generic_credential_name(field_name) + for field_name in raw_entry + ) + ): + raise ValueError( + "Persisted unknown manifest entries cannot contain credential-like fields" + ) + if ( + mount_class is None + and "mount_strategy" in raw_entry + and not isinstance(entry_type, str) + ): + raise ValueError("Persisted unregistered mount type must be a string") removed = False if looks_like_mount: + _validate_mount_entry_reserved_structure(raw_entry) + if ( + mount_class is not None + and _raw_mount_uses_in_container_strategy(raw_entry) + and any( + raw_entry.get(field_name) is not None + for field_name in mount_class._ambient_credential_reference_field_names + ) + ): + raise ValueError( + "Persisted in-container mounts cannot select an ambient cloud identity" + ) credential_fields = ( mount_class._credential_field_names if mount_class is not None else all_credential_fields ) - for field_name in credential_fields: - if field_name in raw_entry: - value = raw_entry.pop(field_name) - removed = removed or value is not None + credential_file_fields = ( + mount_class._credential_file_field_names + if mount_class is not None + else all_credential_file_fields + ) + credential_field_names = frozenset(credential_fields) + credential_file_field_names = frozenset(credential_file_fields) + all_credential_field_names = frozenset(all_credential_fields) + all_credential_file_field_names = frozenset(all_credential_file_fields) + all_credential_driver_option_names_frozen = frozenset( + all_credential_driver_option_names + ) + removed_fields: list[str] = [] + if mount_class is None and "mount_strategy" in raw_entry: + released_safe_mount_fields = _RELEASED_UNIMPORTED_MOUNT_SAFE_FIELDS.get( + cast(str, entry_type), + frozenset(), + ) + for field_name in list(raw_entry): + if isinstance(field_name, str) and _field_contains_inline_url_credentials( + field_name, + raw_entry[field_name], + url_field_names=( + mount_class._url_field_names if mount_class is not None else frozenset() + ), + ): + raise ValueError( + "Persisted mount endpoint URLs cannot contain inline credentials" + ) + if ( + isinstance(field_name, str) + and field_name in _RELEASED_UNIMPORTED_MOUNT_BASE_SAFE_FIELDS + ): + _validate_released_unimported_mount_base_field( + field_name, + raw_entry[field_name], + ) + continue + if ( + not isinstance(field_name, str) + or field_name in _RAW_UNREGISTERED_MOUNT_SAFE_FIELDS + or field_name in released_safe_mount_fields + ): + continue + field_value = raw_entry.pop(field_name) + if _references_raw_manifest_entry_value( + field_value, + root=root, + entry_paths=entry_paths, + ): + raise ValueError( + "Persisted mount credential source must not be a manifest entry" + ) + removed = removed or field_value is not None + if field_value is not None: + removed_fields.append(_RAW_MOUNT_CREDENTIAL_SLOT) + for field_name in list(raw_entry): + if not isinstance(field_name, str): + continue + if _field_contains_inline_url_credentials( + field_name, + raw_entry[field_name], + url_field_names=( + mount_class._url_field_names if mount_class is not None else frozenset() + ), + ): + raise ValueError( + "Persisted mount endpoint URLs cannot contain inline credentials" + ) + matched_field_name = next( + ( + known_name + for known_name in sorted(credential_field_names) + if _matches_credential_field_name( + field_name, + credential_field_names=frozenset({known_name}), + ) + ), + None, + ) + if matched_field_name is None and not _is_generic_credential_name(field_name): + continue + value = raw_entry[field_name] + normalized_field_name = _normalize_driver_option_name(field_name) + is_credential_file = ( + matched_field_name in credential_file_field_names + or normalized_field_name.endswith(("-file", "-path")) + ) + if ( + isinstance(value, str) + and is_credential_file + and _references_raw_manifest_entry( + value, + root=root, + entry_paths=entry_paths, + ) + ): + raise ValueError( + "Persisted mount credential source must not be a manifest entry" + ) + removed_value = raw_entry.pop(field_name) + removed = removed or removed_value is not None + if removed_value is not None: + removed_fields.append( + matched_field_name + if mount_class is not None + and matched_field_name in _SDK_DIRECT_MOUNT_CREDENTIAL_SLOTS + else _RAW_MOUNT_CREDENTIAL_SLOT + ) + + if raw_entry.get("type") == "s3_files_mount" and "extra_options" in raw_entry: + extra_options = raw_entry["extra_options"] + if not isinstance( + extra_options, Mapping + ) or _configured_option_map_credential_fields( + extra_options, + field_prefix="extra_options", + ): + raise ValueError( + "Persisted S3 Files mount options cannot contain credentials or invalid " + "values" + ) + + if mount_class is not None: + allowed_mount_fields = frozenset(mount_class.model_fields) + for field_name in list(raw_entry): + if isinstance(field_name, str) and field_name in allowed_mount_fields: + continue + raw_entry.pop(field_name) if "mount_strategy" in raw_entry: strategy = raw_entry["mount_strategy"] if not isinstance(strategy, dict): raise ValueError("Persisted sandbox mount_strategy must be an object") + strategy_type = strategy.get("type") + if not isinstance(strategy_type, str): + raise ValueError("Persisted mount strategy type must be a string") + strategy_class = MountStrategyBase._subclass_registry.get(strategy_type) + if strategy_class is None: + released_safe_strategy_fields = _RELEASED_UNIMPORTED_STRATEGY_SAFE_FIELDS.get( + strategy_type, + frozenset(), + ) + safe_reference_fields = ( + _RELEASED_UNIMPORTED_STRATEGY_CREDENTIAL_REFERENCE_FIELDS.get( + strategy_type, + frozenset(), + ) + ) + for field_name in list(strategy): + if isinstance(field_name, str) and _field_contains_inline_url_credentials( + field_name, + strategy[field_name], + url_field_names=( + strategy_class._url_field_names + if strategy_class is not None + else frozenset() + ), + ): + raise ValueError( + "Persisted mount endpoint URLs cannot contain inline credentials" + ) + if isinstance(field_name, str) and ( + field_name in _RAW_UNREGISTERED_MOUNT_STRATEGY_SAFE_FIELDS + or field_name in released_safe_strategy_fields + or field_name in safe_reference_fields + ): + if field_name in safe_reference_fields and not isinstance( + strategy[field_name], str | None + ): + raise ValueError( + "Persisted mount strategy reference has an invalid shape" + ) + continue + field_value = strategy.pop(field_name) + if _references_raw_manifest_entry_value( + field_value, + root=root, + entry_paths=entry_paths, + ): + raise ValueError( + "Persisted mount credential source must not be a manifest entry" + ) + removed = removed or field_value is not None + if field_value is not None: + removed_fields.append(_RAW_MOUNT_CREDENTIAL_SLOT) + else: + allowed_strategy_fields = frozenset(strategy_class.model_fields) + for field_name in list(strategy): + if isinstance(field_name, str) and field_name in allowed_strategy_fields: + continue + strategy.pop(field_name) + + declared_strategy_credentials = strategy_class._credential_field_names + declared_strategy_credential_files = strategy_class._credential_file_field_names + strategy_credential_references = ( + strategy_class._credential_reference_field_names + ) + for field_name in list(strategy): + if not isinstance(field_name, str) or field_name == "type": + continue + if _field_contains_inline_url_credentials( + field_name, + strategy[field_name], + url_field_names=strategy_class._url_field_names, + ): + raise ValueError( + "Persisted mount endpoint URLs cannot contain inline credentials" + ) + is_declared = field_name in declared_strategy_credentials + if not is_declared and ( + field_name in strategy_credential_references + or not _is_generic_credential_name(field_name) + ): + continue + field_value = strategy[field_name] + normalized_field_name = _normalize_driver_option_name(field_name) + is_credential_file = ( + field_name in declared_strategy_credential_files + or normalized_field_name.endswith(("-file", "-path")) + ) + if ( + isinstance(field_value, str) + and is_credential_file + and _references_raw_manifest_entry( + field_value, + root=root, + entry_paths=entry_paths, + ) + ): + raise ValueError( + "Persisted mount credential source must not be a manifest entry" + ) + removed_value = strategy.pop(field_name) + removed = removed or removed_value is not None + if removed_value is not None: + removed_fields.append( + _STRATEGY_CREDENTIAL_SLOT + if is_declared + else _RAW_MOUNT_CREDENTIAL_SLOT + ) + _reject_noncanonical_mapping_key( + strategy, + canonical_key="driver_options", + message="Persisted sandbox driver_options has an invalid field name", + ) + if "driver_options" in strategy: + driver_options = strategy["driver_options"] + if not isinstance(driver_options, dict): + raise ValueError("Persisted sandbox driver_options must be an object") + for option_name in list(driver_options): + if not isinstance(option_name, str): + raise ValueError( + "Persisted sandbox driver_options keys must be strings" + ) + option_value = driver_options[option_name] + option_kind = _rclone_driver_option_credential_kind( + option_name, + option_value if isinstance(option_value, str) else None, + credential_field_names=all_credential_field_names, + credential_driver_option_names=( + all_credential_driver_option_names_frozen + ), + ) + if option_kind == "unsafe": + raise ValueError( + "Persisted rclone driver options cannot expose credentials" + ) + if option_kind == "none": + continue + if has_serialized_files and _is_rclone_opaque_credential_authority_option( + option_name + ): + raise ValueError( + "Persisted opaque mount credential authorities cannot be combined " + "with serialized manifest files" + ) + if ( + isinstance(option_value, str) + and _is_credential_file_driver_option_name( + option_name, + credential_field_names=all_credential_field_names, + credential_file_field_names=all_credential_file_field_names, + credential_driver_option_names=( + all_credential_driver_option_names_frozen + ), + ) + and _references_raw_manifest_entry( + option_value, + root=root, + entry_paths=entry_paths, + ) + ): + raise ValueError( + "Persisted mount credential source must not be a manifest entry" + ) + removed_option = driver_options.pop(option_name) + removed = removed or removed_option is not None + if removed_option is not None: + removed_fields.append( + _DRIVER_OPTIONS_CREDENTIAL_SLOT + if mount_class is not None + else _RAW_MOUNT_CREDENTIAL_SLOT + ) if "pattern" in strategy: pattern = strategy["pattern"] if not isinstance(pattern, dict): raise ValueError("Persisted sandbox mount pattern must be an object") + if _configured_pattern_credential_fields(pattern): + raise ValueError( + "Persisted mount patterns cannot contain inline credential " + "configuration" + ) + extra_args = pattern.get("extra_args") + if "extra_args" in pattern and ( + not isinstance(extra_args, list) + or not all(isinstance(argument, str) for argument in extra_args) + ): + raise ValueError("Persisted rclone extra_args must contain strings") + if isinstance(extra_args, list) and _rclone_extra_args_use_credential_source( + extra_args + ): + raise ValueError( + "Persisted rclone extra_args cannot configure or expose credentials " + "or select a config file" + ) + _reject_noncanonical_mapping_key( + pattern, + canonical_key="config_file_path", + message=( + "Persisted sandbox mount pattern has an invalid field name for " + "credentials" + ), + ) config_file_path = pattern.get("config_file_path") nested_pattern = { key: value for key, value in pattern.items() if key != "config_file_path" } - if _contains_nested_key(nested_pattern, "config_file_path"): + if _contains_nested_normalized_key(nested_pattern, "configfilepath"): raise ValueError( "Persisted sandbox mount pattern contains an invalid credential source" ) @@ -363,13 +1264,44 @@ def _sanitize_entry_mapping( entry_paths=entry_paths, ): raise ValueError( - "Persisted rclone credential source must not be a manifest entry" + "Persisted mount credential source must not be a manifest entry" ) if "config_file_path" in pattern: - removed = removed or pattern.pop("config_file_path") is not None + removed_config_path = pattern.pop("config_file_path") + removed = removed or removed_config_path is not None + if removed_config_path is not None: + removed_fields.append( + _RCLONE_CONFIG_CREDENTIAL_SLOT + if mount_class is not None + else _RAW_MOUNT_CREDENTIAL_SLOT + ) + + if mount_class is None: + released_safe_strategy_fields = _RELEASED_UNIMPORTED_STRATEGY_SAFE_FIELDS.get( + strategy_type, + frozenset(), + ) + for field_name in list(strategy): + if isinstance(field_name, str) and ( + field_name in _RAW_UNREGISTERED_MOUNT_STRATEGY_SAFE_FIELDS + or field_name in released_safe_strategy_fields + ): + continue + field_value = strategy.pop(field_name) + if _references_raw_manifest_entry_value( + field_value, + root=root, + entry_paths=entry_paths, + ): + raise ValueError( + "Persisted mount credential source must not be a manifest entry" + ) + removed = removed or field_value is not None + if field_value is not None: + removed_fields.append(_RAW_MOUNT_CREDENTIAL_SLOT) if removed: - credential_paths.append(entry_path) + credentials_by_path.setdefault(entry_path, []).extend(removed_fields) children = raw_entry.get("children") if "children" in raw_entry and not isinstance(children, dict): @@ -378,12 +1310,25 @@ def _sanitize_entry_mapping( _sanitize_entry_mapping( children, prefix=entry_path, - credential_paths=credential_paths, + credentials_by_path=credentials_by_path, entry_paths=entry_paths, + has_serialized_files=has_serialized_files, root=root, ) +def _raw_entries_contain_serialized_file(entries: dict[Any, Any]) -> bool: + for raw_entry in entries.values(): + if not isinstance(raw_entry, dict): + continue + if raw_entry.get("type") == "file": + return True + children = raw_entry.get("children") + if isinstance(children, dict) and _raw_entries_contain_serialized_file(children): + return True + return False + + def _collect_raw_entry_paths( entries: dict[Any, Any], *, @@ -416,16 +1361,133 @@ def _references_raw_manifest_entry( normalized_path = Manifest._normalize_in_workspace_path(root, resolved_path) if normalized_path is None: return False - return normalized_path.relative_to(root).as_posix() in entry_paths + relative_path = normalized_path.relative_to(root) + return any( + relative_path == Path(entry_path) or Path(entry_path) in relative_path.parents + for entry_path in entry_paths + ) except (InvalidManifestPathError, TypeError, ValueError): return False -def _contains_nested_key(value: object, key: str) -> bool: +def _references_raw_manifest_entry_value( + value: object, + *, + root: Path, + entry_paths: frozenset[str], +) -> bool: + if isinstance(value, str): + return _references_raw_manifest_entry(value, root=root, entry_paths=entry_paths) + if isinstance(value, Mapping): + return any( + _references_raw_manifest_entry_value( + nested_value, + root=root, + entry_paths=entry_paths, + ) + for nested_value in value.values() + ) + if isinstance(value, list | tuple): + return any( + _references_raw_manifest_entry_value( + nested_value, + root=root, + entry_paths=entry_paths, + ) + for nested_value in value + ) + return False + + +def _reject_noncanonical_mapping_key( + value: Mapping[object, object], + *, + canonical_key: str, + message: str, +) -> None: + compact_key = _compact_structural_key(canonical_key) + if any( + isinstance(key, str) + and key != canonical_key + and _compact_structural_key(key) == compact_key + for key in value + ): + raise ValueError(message) + + +def _compact_structural_key(name: str) -> str: + return "".join(character for character in name.casefold() if character.isalnum()) + + +def _validate_mount_entry_reserved_structure(raw_entry: dict[str, object]) -> None: + entry_without_children = {key: value for key, value in raw_entry.items() if key != "children"} + reserved_locations = { + "mount_strategy": ("mount_strategy",), + "driver_options": ("mount_strategy", "driver_options"), + "pattern": ("mount_strategy", "pattern"), + "config_file_path": ("mount_strategy", "pattern", "config_file_path"), + "extra_args": ("mount_strategy", "pattern", "extra_args"), + } + for canonical_key, allowed_path in reserved_locations.items(): + _validate_canonical_structural_key_location( + entry_without_children, + canonical_key=canonical_key, + allowed_path=allowed_path, + invalid_message=(f"Persisted sandbox mount {canonical_key} has an invalid field name"), + ambiguous_message=( + f"Persisted sandbox mount {canonical_key} has an ambiguous location" + ), + ) + + +def _validate_canonical_structural_key_location( + value: object, + *, + canonical_key: str, + allowed_path: tuple[str, ...], + invalid_message: str, + ambiguous_message: str, + path: tuple[str, ...] = (), +) -> None: + if isinstance(value, dict): + compact_key = _compact_structural_key(canonical_key) + for key, item in value.items(): + item_path = (*path, key) if isinstance(key, str) else path + if isinstance(key, str) and _compact_structural_key(key) == compact_key: + if key != canonical_key: + raise ValueError(invalid_message) + if item_path != allowed_path: + raise ValueError(ambiguous_message) + continue + _validate_canonical_structural_key_location( + item, + canonical_key=canonical_key, + allowed_path=allowed_path, + invalid_message=invalid_message, + ambiguous_message=ambiguous_message, + path=item_path, + ) + elif isinstance(value, list | tuple): + for item in value: + _validate_canonical_structural_key_location( + item, + canonical_key=canonical_key, + allowed_path=allowed_path, + invalid_message=invalid_message, + ambiguous_message=ambiguous_message, + path=path, + ) + + +def _contains_nested_normalized_key(value: object, compact_key: str) -> bool: if isinstance(value, dict): - return key in value or any(_contains_nested_key(item, key) for item in value.values()) - if isinstance(value, list): - return any(_contains_nested_key(item, key) for item in value) + if any( + isinstance(key, str) and _compact_structural_key(key) == compact_key for key in value + ): + return True + return any(_contains_nested_normalized_key(item, compact_key) for item in value.values()) + if isinstance(value, list | tuple): + return any(_contains_nested_normalized_key(item, compact_key) for item in value) return False @@ -435,31 +1497,54 @@ def _without_credentials(mount: Mount) -> Mount: return redacted -def _reject_serialized_rclone_config_source( +def _canonical_credential_slots(field_names: tuple[str, ...]) -> tuple[str, ...]: + slots: set[str] = set() + for field_name in field_names: + if field_name == _DRIVER_OPTIONS_CREDENTIAL_SLOT or field_name.startswith( + "mount_strategy.driver_options." + ): + slots.add(_DRIVER_OPTIONS_CREDENTIAL_SLOT) + elif field_name == _RCLONE_CONFIG_CREDENTIAL_SLOT: + slots.add(_RCLONE_CONFIG_CREDENTIAL_SLOT) + elif field_name == _STRATEGY_CREDENTIAL_SLOT or ( + field_name.startswith("mount_strategy.") + and not field_name.startswith("mount_strategy.driver_options.") + ): + slots.add(_STRATEGY_CREDENTIAL_SLOT) + elif field_name in _SDK_DIRECT_MOUNT_CREDENTIAL_SLOTS: + slots.add(field_name) + else: + slots.add(_RAW_MOUNT_CREDENTIAL_SLOT) + return tuple(sorted(slots)) + + +def _reject_serialized_credential_file_sources( manifest: Manifest, mount: Mount, mount_path: Path, ) -> None: - pattern = mount._rclone_pattern() - if pattern is None or pattern.config_file_path is None: - return - root = posix_path_as_path(coerce_posix_path(manifest.root)) - config_path = pattern.config_file_path - resolved_path = config_path if config_path.is_absolute() else root / config_path - normalized_path = manifest._normalize_in_workspace_path(root, resolved_path) - if normalized_path is None: - return - relative_path = normalized_path.relative_to(root) - if any(entry_path == relative_path for entry_path, _entry in manifest.iter_entries()): + manifest_entry_paths = {entry_path for entry_path, _entry in manifest.iter_entries()} + for field_name, config_path in mount._configured_credential_file_paths(): + resolved_path = config_path if config_path.is_absolute() else root / config_path + normalized_path = manifest._normalize_in_workspace_path(root, resolved_path) + if normalized_path is None: + continue + relative_path = normalized_path.relative_to(root) + if not any( + relative_path == entry_path or entry_path in relative_path.parents + for entry_path in manifest_entry_paths + ): + continue raise MountConfigError( message=( - "rclone credential config must not be supplied by a serialized manifest entry; " - "provide it through trusted live sandbox configuration" + "mount credential files must not be supplied by a serialized manifest entry; " + "provide them through trusted live sandbox configuration" ), context={ "mount_path": mount_path.as_posix(), "mount_type": mount.type, + "credential_field": field_name, "config_path": relative_path.as_posix(), }, ) diff --git a/src/agents/sandbox/entries/base.py b/src/agents/sandbox/entries/base.py index 2f5ba4e36d..337593271f 100644 --- a/src/agents/sandbox/entries/base.py +++ b/src/agents/sandbox/entries/base.py @@ -153,8 +153,7 @@ def parse(cls, payload: object) -> BaseEntry: entry_cls = BaseEntry._subclass_registry.get(entry_type) if entry_cls is None: - known = ", ".join(sorted(BaseEntry._subclass_registry)) or "" - raise ValueError(f"Unknown artifact type `{entry_type}`. Registered types: {known}") + raise ValueError("Artifact entry mapping contains an unknown `type` field") return entry_cls.model_validate(dict(payload)) async def _apply_metadata( diff --git a/src/agents/sandbox/entries/mounts/base.py b/src/agents/sandbox/entries/mounts/base.py index 5fc1808c64..07b870be12 100644 --- a/src/agents/sandbox/entries/mounts/base.py +++ b/src/agents/sandbox/entries/mounts/base.py @@ -3,13 +3,27 @@ import abc import builtins import inspect +import re import warnings from collections.abc import Mapping from pathlib import Path from typing import TYPE_CHECKING, Any, ClassVar, Literal, cast - -from pydantic import BaseModel, Field, SerializeAsAny, field_validator - +from urllib.parse import urlsplit + +from pydantic import ( + BaseModel, + Field, + SerializeAsAny, + ValidationError, + field_validator, + model_validator, +) + +from ....exceptions import ( + _data_redacted_async_boundary, + _mark_error_data_redacted, + _raise_data_redacted_error, +) from ...errors import InvalidManifestPathError, MountConfigError from ...materialization import MaterializedFile from ...types import FileMode, Permissions @@ -24,10 +38,547 @@ _MountCredentialBoundary = Literal[ "inside_sandbox", "outside_sandbox", - "client_opt_in", "unknown", ] +_DRIVER_OPTION_CREDENTIAL_MARKERS = ( + "access-key", + "api-key", + "credential", + "password", + "passwd", + "private-key", + "secret", + "token", +) +_AUDITED_RCLONE_VERSION = "1.74.4" +# This is the audited credential/config-source set from rclone v1.74.4's registered backend +# options, plus credential-bearing global server and transport options. Keep the pin assertion in +# the security tests synchronized with the SDK-managed rclone release. +_RCLONE_PINNED_CREDENTIAL_OPTIONS = frozenset( + { + "alias-remote", + "archive-remote", + "azureblob-client-certificate-password", + "azureblob-client-certificate-path", + "azureblob-client-certificate-pem", + "azureblob-client-secret", + "azureblob-connection-string", + "azureblob-key", + "azureblob-password", + "azureblob-sas-url", + "azureblob-service-principal-file", + "azurefiles-client-certificate-password", + "azurefiles-client-certificate-path", + "azurefiles-client-secret", + "azurefiles-connection-string", + "azurefiles-key", + "azurefiles-password", + "azurefiles-sas-url", + "azurefiles-service-principal-file", + "b2-account", + "b2-key", + "b2-sse-customer-key", + "b2-sse-customer-key-base64", + "b2-sse-customer-key-md5", + "box-access-token", + "box-box-config-file", + "box-client-secret", + "box-config-credentials", + "box-token", + "cache-plex-password", + "cache-plex-token", + "cache-remote", + "client-cert", + "client-key", + "client-pass", + "cloudinary-api-key", + "cloudinary-api-secret", + "chunker-remote", + "combine-upstreams", + "compress-remote", + "crypt-password", + "crypt-password2", + "crypt-remote", + "drime-access-token", + "drive-client-secret", + "drive-resource-key", + "drive-service-account-credentials", + "drive-service-account-file", + "drive-token", + "dropbox-client-secret", + "dropbox-token", + "fichier-api-key", + "fichier-file-password", + "fichier-folder-password", + "filefabric-permanent-token", + "filefabric-token", + "filelu-key", + "filen-api-key", + "filen-master-keys", + "filen-password", + "filen-private-key", + "filescom-api-key", + "filescom-password", + "ftp-pass", + "fs", + "gcs-access-token", + "gcs-client-secret", + "gcs-service-account-credentials", + "gcs-service-account-file", + "gcs-token", + "gofile-access-token", + "gphotos-client-secret", + "gphotos-token", + "header", + "header-download", + "header-upload", + "hasher-remote", + "hidrive-client-secret", + "hidrive-token", + "http-headers", + "huaweidrive-client-secret", + "huaweidrive-token", + "iclouddrive-cookies", + "iclouddrive-password", + "iclouddrive-trust-token", + "imagekit-private-key", + "internetarchive-access-key-id", + "internetarchive-secret-access-key", + "internxt-mnemonic", + "internxt-pass", + "jottacloud-client-secret", + "jottacloud-token", + "koofr-password", + "linkbox-password", + "linkbox-token", + "mailru-client-secret", + "mailru-pass", + "mailru-token", + "mega-master-key", + "mega-pass", + "mega-session-id", + "metrics-cert", + "metrics-client-ca", + "metrics-htpasswd", + "metrics-key", + "metrics-pass", + "netstorage-account", + "netstorage-secret", + "onedrive-client-secret", + "onedrive-link-password", + "onedrive-token", + "oos-config-file", + "oos-config-profile", + "oos-sse-customer-key", + "oos-sse-customer-key-file", + "oos-sse-customer-key-sha256", + "opendrive-password", + "password-command", + "pcloud-client-secret", + "pcloud-password", + "pcloud-token", + "pikpak-pass", + "pixeldrain-api-key", + "premiumizeme-api-key", + "premiumizeme-client-secret", + "premiumizeme-token", + "protondrive-client-access-token", + "protondrive-client-refresh-token", + "protondrive-client-salted-key-pass", + "protondrive-mailbox-password", + "protondrive-otp-secret-key", + "protondrive-password", + "putio-client-secret", + "putio-token", + "qingstor-access-key-id", + "qingstor-secret-access-key", + "quatrix-api-key", + "rc-cert", + "rc-client-ca", + "rc-htpasswd", + "rc-key", + "rc-pass", + "remote", + "s3-access-key-id", + "s3-profile", + "s3-secret-access-key", + "s3-session-token", + "s3-shared-credentials-file", + "s3-sse-customer-key", + "s3-sse-customer-key-base64", + "s3-sse-customer-key-md5", + "seafile-auth-token", + "seafile-library-key", + "seafile-pass", + "sftp-key-file", + "sftp-key-file-pass", + "sftp-key-pem", + "sftp-pass", + "sftp-ssh", + "shade-api-key", + "sharefile-client-secret", + "sharefile-token", + "sia-api-password", + "smb-kerberos-ccache", + "smb-pass", + "storj-access-grant", + "storj-api-key", + "storj-passphrase", + "sugarsync-access-key-id", + "sugarsync-app-id", + "sugarsync-authorization", + "sugarsync-private-access-key", + "sugarsync-refresh-token", + "swift-application-credential-id", + "swift-application-credential-name", + "swift-application-credential-secret", + "swift-auth-token", + "swift-key", + "tardigrade-access-grant", + "tardigrade-api-key", + "tardigrade-passphrase", + "ulozto-app-token", + "ulozto-password", + "union-upstreams", + "webdav-bearer-token", + "webdav-bearer-token-command", + "webdav-pass", + "yandex-client-secret", + "yandex-token", + "zoho-client-secret", + "zoho-token", + } +) +# File-valued credential sources need explicit metadata because audited options such as +# ``client-key`` and ``smb-kerberos-ccache`` do not have a file-like suffix. +_RCLONE_PINNED_CREDENTIAL_FILE_OPTIONS = frozenset( + { + "azureblob-client-certificate-path", + "azureblob-service-principal-file", + "azurefiles-client-certificate-path", + "azurefiles-service-principal-file", + "box-box-config-file", + "client-cert", + "client-key", + "drive-service-account-file", + "gcs-service-account-file", + "metrics-cert", + "metrics-client-ca", + "metrics-htpasswd", + "metrics-key", + "oos-config-file", + "oos-sse-customer-key-file", + "rc-cert", + "rc-client-ca", + "rc-htpasswd", + "rc-key", + "s3-shared-credentials-file", + "sftp-key-file", + "smb-kerberos-ccache", + } +) +assert _RCLONE_PINNED_CREDENTIAL_FILE_OPTIONS <= _RCLONE_PINNED_CREDENTIAL_OPTIONS +_RCLONE_PINNED_CREDENTIAL_COMMAND_OPTIONS = frozenset( + { + "password-command", + "sftp-ssh", + "webdav-bearer-token-command", + } +) +assert _RCLONE_PINNED_CREDENTIAL_COMMAND_OPTIONS <= _RCLONE_PINNED_CREDENTIAL_OPTIONS +_RCLONE_PINNED_CONNECTION_STRING_OPTIONS = frozenset( + { + "alias-remote", + "archive-remote", + "cache-remote", + "chunker-remote", + "combine-upstreams", + "compress-remote", + "crypt-remote", + "fs", + "hasher-remote", + "remote", + "union-upstreams", + } +) +assert _RCLONE_PINNED_CONNECTION_STRING_OPTIONS <= _RCLONE_PINNED_CREDENTIAL_OPTIONS +_RCLONE_PINNED_OPAQUE_CREDENTIAL_AUTHORITY_OPTIONS = ( + _RCLONE_PINNED_CREDENTIAL_COMMAND_OPTIONS | _RCLONE_PINNED_CONNECTION_STRING_OPTIONS +) +_RCLONE_S3_CREDENTIAL_DRIVER_OPTION_NAMES = frozenset( + option_name + for option_name in _RCLONE_PINNED_CREDENTIAL_OPTIONS + if option_name.startswith("s3-") +) +_RCLONE_CREDENTIAL_EXPOSING_DUMP_VALUES = frozenset( + {"auth", "bodies", "errors", "headers", "requests", "responses"} +) +_RCLONE_CREDENTIAL_EXPOSING_BOOLEAN_OPTIONS = frozenset({"dump-bodies", "dump-headers", "rc"}) +_RCLONE_FALSE_BOOLEAN_VALUES = frozenset({"0", "f", "false"}) +_RELEASED_UNIMPORTED_MOUNT_SAFE_FIELDS = { + "blaxel_drive_mount": frozenset( + {"drive_mount_path", "drive_name", "drive_path", "drive_read_only"} + ), +} +_RELEASED_UNIMPORTED_STRATEGY_SAFE_FIELDS = { + "daytona_cloud_bucket": frozenset({"pattern"}), + "e2b_cloud_bucket": frozenset({"pattern"}), + "modal_cloud_bucket": frozenset({"secret_environment_name", "secret_name"}), + "runloop_cloud_bucket": frozenset({"pattern"}), +} +_RELEASED_UNIMPORTED_STRATEGY_CREDENTIAL_REFERENCE_FIELDS = { + "modal_cloud_bucket": frozenset({"secret_environment_name", "secret_name"}), +} + + +def _normalize_driver_option_name(name: str) -> str: + camel_separated = re.sub(r"(?<=[a-z0-9])(?=[A-Z])", "-", name) + return re.sub(r"[^a-z0-9]+", "-", camel_separated.lower()).strip("-") + + +def _compact_driver_option_name(name: str) -> str: + return _normalize_driver_option_name(name).replace("-", "") + + +def _matches_credential_field_name( + name: str, + *, + credential_field_names: frozenset[str], +) -> bool: + compact_name = _compact_driver_option_name(name) + return any( + compact_name == _compact_driver_option_name(field_name) + for field_name in credential_field_names + ) + + +def _is_generic_credential_name(name: str) -> bool: + tokens = tuple(token for token in _normalize_driver_option_name(name).split("-") if token) + marker_tokens = tuple(tuple(marker.split("-")) for marker in _DRIVER_OPTION_CREDENTIAL_MARKERS) + if any( + tokens[index : index + len(marker)] == marker + for marker in marker_tokens + for index in range(len(tokens) - len(marker) + 1) + ): + return True + compact_name = _compact_driver_option_name(name) + return any( + compact_name + in { + _compact_driver_option_name(marker), + f"{_compact_driver_option_name(marker)}file", + f"{_compact_driver_option_name(marker)}path", + } + for marker in _DRIVER_OPTION_CREDENTIAL_MARKERS + ) + + +def _contains_normalized_name_tokens(name: str, fragment: str) -> bool: + tokens = tuple(token for token in _normalize_driver_option_name(name).split("-") if token) + fragment_tokens = tuple( + token for token in _normalize_driver_option_name(fragment).split("-") if token + ) + return any( + tokens[index : index + len(fragment_tokens)] == fragment_tokens + for index in range(len(tokens) - len(fragment_tokens) + 1) + ) + + +def _all_registered_mount_credential_metadata() -> tuple[ + frozenset[str], frozenset[str], frozenset[str] +]: + mount_classes = ( + entry_class + for entry_class in BaseEntry.registered_types().values() + if issubclass(entry_class, Mount) + ) + credential_fields: set[str] = set() + credential_file_fields: set[str] = set() + credential_driver_option_names: set[str] = set() + for mount_class in mount_classes: + credential_fields.update(mount_class._credential_field_names) + credential_file_fields.update(mount_class._credential_file_field_names) + credential_driver_option_names.update(mount_class._credential_driver_option_names) + return ( + frozenset(credential_fields), + frozenset(credential_file_fields), + frozenset(credential_driver_option_names), + ) + + +def _is_credential_driver_option_name( + name: str, + *, + credential_field_names: frozenset[str], + credential_driver_option_names: frozenset[str] = frozenset(), +) -> bool: + normalized_name = _normalize_driver_option_name(name) + compact_name = _compact_driver_option_name(name) + field_fragments = { + _normalize_driver_option_name(field_name) for field_name in credential_field_names + } + exact_option_names = { + _normalize_driver_option_name(option_name) for option_name in credential_driver_option_names + } + if normalized_name in _RCLONE_PINNED_CREDENTIAL_OPTIONS: + return True + if normalized_name in exact_option_names or compact_name in { + _compact_driver_option_name(option_name) for option_name in exact_option_names + }: + return True + if any( + _contains_normalized_name_tokens(name, fragment) for fragment in field_fragments + ) or compact_name in {_compact_driver_option_name(fragment) for fragment in field_fragments}: + return True + return _is_generic_credential_name(name) + + +def _rclone_driver_option_credential_kind( + name: str, + value: str | None, + *, + credential_field_names: frozenset[str], + credential_driver_option_names: frozenset[str] = frozenset(), +) -> Literal["credential", "none", "unsafe"]: + """Classify rclone driver configuration without retaining credential values.""" + + normalized_name = _normalize_driver_option_name(name) + if normalized_name in {"config", "config-file", "config-path"}: + return "credential" + if normalized_name in _RCLONE_CREDENTIAL_EXPOSING_BOOLEAN_OPTIONS: + if value is not None and value.casefold() in _RCLONE_FALSE_BOOLEAN_VALUES: + return "none" + return "unsafe" + if normalized_name == "dump" and value is not None: + dump_values = {item.strip().casefold() for item in value.split(",") if item.strip()} + return "unsafe" if dump_values & _RCLONE_CREDENTIAL_EXPOSING_DUMP_VALUES else "none" + if normalized_name == "http-proxy" and value is not None: + return "credential" if _url_contains_inline_credentials(value) else "none" + if value is not None and _field_contains_inline_url_credentials(name, value): + return "credential" + if _is_credential_driver_option_name( + name, + credential_field_names=credential_field_names, + credential_driver_option_names=credential_driver_option_names, + ): + return "credential" + return "none" + + +def _is_credential_file_driver_option_name( + name: str, + *, + credential_field_names: frozenset[str], + credential_file_field_names: frozenset[str], + credential_driver_option_names: frozenset[str] = frozenset(), +) -> bool: + normalized_name = _normalize_driver_option_name(name) + compact_name = _compact_driver_option_name(name) + file_field_fragments = { + _normalize_driver_option_name(field_name) for field_name in credential_file_field_names + } + compact_file_field_fragments = { + _compact_driver_option_name(field_name) for field_name in file_field_fragments + } + return _is_credential_driver_option_name( + name, + credential_field_names=credential_field_names, + credential_driver_option_names=credential_driver_option_names, + ) and ( + normalized_name in _RCLONE_PINNED_CREDENTIAL_FILE_OPTIONS + or any(fragment in normalized_name for fragment in file_field_fragments) + or any(fragment in compact_name for fragment in compact_file_field_fragments) + or normalized_name.endswith(("-file", "-path")) + or compact_name.endswith(("file", "path")) + ) + + +def _rclone_option_value(extra_args: list[str], index: int, inline_value: str | None) -> str | None: + if inline_value is not None: + return inline_value + if index + 1 >= len(extra_args) or extra_args[index + 1].startswith("-"): + return None + return extra_args[index + 1] + + +def _is_rclone_opaque_credential_authority_option(name: str) -> bool: + return _normalize_driver_option_name(name) in ( + _RCLONE_PINNED_OPAQUE_CREDENTIAL_AUTHORITY_OPTIONS + ) + + +def _url_contains_inline_credentials(value: str) -> bool: + candidate = value if "://" in value else f"//{value}" + try: + parsed = urlsplit(candidate) + except ValueError: + return any(delimiter in value for delimiter in ("@", "?", "#")) + return bool( + parsed.username is not None + or parsed.password is not None + or "@" in parsed.netloc + or parsed.query + or parsed.fragment + ) + + +def _field_contains_inline_url_credentials( + name: str, + value: object, + *, + url_field_names: frozenset[str] = frozenset(), +) -> bool: + """Return whether a URL-like configuration field embeds user information.""" + + tokens = frozenset(_normalize_driver_option_name(name).split("-")) + return ( + isinstance(value, str) + and (name in url_field_names or bool(tokens & {"endpoint", "url"})) + and _url_contains_inline_credentials(value) + ) + + +def _configured_option_map_credential_fields( + options: Mapping[object, object], + *, + field_prefix: str, +) -> tuple[str, ...]: + """Return credential-like option names without retaining their values.""" + + return (field_prefix,) if options else () + + +def _rclone_extra_args_use_credential_source(extra_args: list[str]) -> bool: + """Return whether rclone arguments override the SDK-owned credential source.""" + + ( + credential_field_names, + _, + credential_driver_option_names, + ) = _all_registered_mount_credential_metadata() + for index, argument in enumerate(extra_args): + if not argument.startswith("-"): + continue + option, separator, inline_value = argument.lstrip("-").partition("=") + option_name = option + option_value = _rclone_option_value( + extra_args, + index, + inline_value if separator else None, + ) + if ( + _rclone_driver_option_credential_kind( + option_name, + option_value, + credential_field_names=credential_field_names, + credential_driver_option_names=credential_driver_option_names, + ) + != "none" + ): + return True + return False + class InContainerMountAdapter: """Default adapter for mounts materialized by commands inside the sandbox. @@ -133,6 +684,10 @@ class MountStrategyBase(BaseModel, abc.ABC): type: str _subclass_registry: ClassVar[dict[str, builtins.type[MountStrategyBase]]] = {} credential_boundary: ClassVar[_MountCredentialBoundary] = "unknown" + _credential_field_names: ClassVar[frozenset[str]] = frozenset() + _credential_file_field_names: ClassVar[frozenset[str]] = frozenset() + _credential_reference_field_names: ClassVar[frozenset[str]] = frozenset() + _url_field_names: ClassVar[frozenset[str]] = frozenset() @classmethod def __pydantic_init_subclass__(cls, **kwargs: object) -> None: @@ -174,6 +729,60 @@ def parse(cls, payload: object) -> MountStrategyBase: ) return strategy_cls.model_validate(dict(payload)) + def _configured_credential_fields(self) -> tuple[str, ...]: + """Return configured credential-bearing strategy fields without their values.""" + + return tuple( + field_name + for field_name in sorted(type(self).model_fields) + if field_name != "type" + and ( + field_name in self._credential_field_names + or ( + field_name not in self._credential_reference_field_names + and _is_generic_credential_name(field_name) + ) + ) + and getattr(self, field_name, None) is not None + ) + + def _configured_undeclared_credential_fields(self) -> tuple[str, ...]: + """Return generic credential fields not declared by the strategy implementation.""" + + return tuple( + field_name + for field_name in self._configured_credential_fields() + if field_name not in self._credential_field_names + ) + + def _configured_inline_url_credential_fields(self) -> tuple[str, ...]: + """Return URL-like fields that embed credentials without returning their values.""" + + return tuple( + field_name + for field_name in sorted(type(self).model_fields) + if _field_contains_inline_url_credentials( + field_name, + getattr(self, field_name, None), + url_field_names=self._url_field_names, + ) + ) + + def _configured_credential_file_paths(self) -> tuple[tuple[str, Path], ...]: + """Return configured strategy credential-file fields without reading them.""" + + configured: list[tuple[str, Path]] = [] + for field_name in self._configured_credential_fields(): + normalized_name = _normalize_driver_option_name(field_name) + if field_name not in self._credential_file_field_names and not normalized_name.endswith( + ("-file", "-path") + ): + continue + value = getattr(self, field_name, None) + if isinstance(value, str | Path): + configured.append((field_name, Path(value))) + return tuple(configured) + @abc.abstractmethod def validate_mount(self, mount: Mount) -> None: raise NotImplementedError @@ -363,6 +972,30 @@ class Mount(BaseEntry): read_only: bool = Field(default=True) mount_strategy: MountStrategy _credential_field_names: ClassVar[frozenset[str]] = frozenset() + _credential_file_field_names: ClassVar[frozenset[str]] = frozenset() + _credential_driver_option_names: ClassVar[frozenset[str]] = frozenset() + _ambient_credential_reference_field_names: ClassVar[frozenset[str]] = frozenset() + _url_field_names: ClassVar[frozenset[str]] = frozenset() + + @model_validator(mode="wrap") + @classmethod + def _redact_input_validation_errors(cls, value: Any, handler: Any) -> Any: + """Replace input-retaining validation failures at the mount credential boundary.""" + + validation_failed = False + try: + return handler(value) + except ValidationError: + validation_failed = True + if validation_failed: + error = MountConfigError( + message="mount configuration has an invalid field value", + context={"mount_type": cls.model_fields["type"].default}, + ) + _mark_error_data_redacted(error) + del value, handler + _raise_data_redacted_error(error) + raise AssertionError("unreachable") @field_validator("mount_strategy", mode="before") @classmethod @@ -433,15 +1066,180 @@ def _configured_credential_fields(self) -> tuple[str, ...]: for field_name in sorted(self._credential_field_names) if getattr(self, field_name, None) is not None ] + configured.extend(self._configured_undeclared_credential_fields()) + configured.extend( + ( + f"mount_strategy.{field_name}" + if field_name in self.mount_strategy._credential_field_names + else "mount.raw_credential" + ) + for field_name in self.mount_strategy._configured_credential_fields() + ) pattern = self._rclone_pattern() if pattern is not None and pattern.config_file_path is not None: configured.append("mount_strategy.pattern.config_file_path") + ( + all_credential_fields, + _, + all_credential_driver_option_names, + ) = _all_registered_mount_credential_metadata() + strategy = self.mount_strategy + if isinstance(strategy, DockerVolumeMountStrategy): + configured.extend( + f"mount_strategy.driver_options.{_normalize_driver_option_name(option_name)}" + for option_name, value in sorted(strategy.driver_options.items()) + if _rclone_driver_option_credential_kind( + option_name, + value, + credential_field_names=all_credential_fields, + credential_driver_option_names=all_credential_driver_option_names, + ) + != "none" + ) + return tuple(dict.fromkeys(configured)) + + def _configured_ambient_credential_reference_fields(self) -> tuple[str, ...]: + """Return fields that select an ambient sandbox identity.""" + + return tuple( + field_name + for field_name in sorted(self._ambient_credential_reference_field_names) + if getattr(self, field_name, None) is not None + ) + + def _configured_undeclared_credential_fields(self) -> tuple[str, ...]: + """Return credential-like mount fields not declared by the mount implementation.""" + + return tuple( + field_name + for field_name in sorted(type(self).model_fields) + if field_name not in self._credential_field_names + and _is_generic_credential_name(field_name) + and getattr(self, field_name, None) is not None + ) + + def _configured_inline_url_credential_fields(self) -> tuple[str, ...]: + """Return URL-like fields that embed credentials without returning their values.""" + + return tuple( + field_name + for field_name in sorted(type(self).model_fields) + if _field_contains_inline_url_credentials( + field_name, + getattr(self, field_name, None), + url_field_names=self._url_field_names, + ) + ) + + def _configured_credential_file_paths(self) -> tuple[tuple[str, Path], ...]: + """Return configured credential-file fields without reading their contents.""" + + configured: list[tuple[str, Path]] = [] + for field_name in sorted(self._credential_file_field_names): + value = getattr(self, field_name, None) + if isinstance(value, str | Path): + configured.append((field_name, Path(value))) + + configured.extend( + (f"mount_strategy.{field_name}", path) + for field_name, path in self.mount_strategy._configured_credential_file_paths() + ) + + pattern = self._rclone_pattern() + if pattern is not None and pattern.config_file_path is not None: + configured.append(("mount_strategy.pattern.config_file_path", pattern.config_file_path)) + + ( + all_credential_fields, + all_credential_file_fields, + all_credential_driver_option_names, + ) = _all_registered_mount_credential_metadata() + strategy = self.mount_strategy + if isinstance(strategy, DockerVolumeMountStrategy): + for option_name, value in sorted(strategy.driver_options.items()): + if not isinstance(value, str): + continue + if _is_credential_file_driver_option_name( + option_name, + credential_field_names=all_credential_fields, + credential_file_field_names=all_credential_file_fields, + credential_driver_option_names=all_credential_driver_option_names, + ): + configured.append( + ( + "mount_strategy.driver_options." + f"{_normalize_driver_option_name(option_name)}", + Path(value), + ) + ) return tuple(configured) + def _configured_opaque_credential_authority_options(self) -> tuple[str, ...]: + strategy = self.mount_strategy + if not isinstance(strategy, DockerVolumeMountStrategy): + return () + return tuple( + sorted( + _normalize_driver_option_name(option_name) + for option_name, value in strategy.driver_options.items() + if value is not None and _is_rclone_opaque_credential_authority_option(option_name) + ) + ) + + def _configured_parameterized_connection_string_options(self) -> tuple[str, ...]: + strategy = self.mount_strategy + if not isinstance(strategy, DockerVolumeMountStrategy): + return () + return tuple( + sorted( + _normalize_driver_option_name(option_name) + for option_name, value in strategy.driver_options.items() + if isinstance(value, str) + and "," in value + and _normalize_driver_option_name(option_name) + in _RCLONE_PINNED_CONNECTION_STRING_OPTIONS + ) + ) + + def _configured_unsafe_driver_options(self) -> tuple[str, ...]: + strategy = self.mount_strategy + if not isinstance(strategy, DockerVolumeMountStrategy): + return () + ( + all_credential_fields, + _, + all_credential_driver_option_names, + ) = _all_registered_mount_credential_metadata() + return tuple( + sorted( + _normalize_driver_option_name(option_name) + for option_name, value in strategy.driver_options.items() + if _rclone_driver_option_credential_kind( + option_name, + value, + credential_field_names=all_credential_fields, + credential_driver_option_names=all_credential_driver_option_names, + ) + == "unsafe" + ) + ) + def _rclone_pattern(self) -> RcloneMountPattern | None: pattern = getattr(self.mount_strategy, "pattern", None) return pattern if isinstance(pattern, RcloneMountPattern) else None + def _internal_persistence_paths(self) -> frozenset[Path]: + pattern = getattr(self.mount_strategy, "pattern", None) + if not isinstance(pattern, MountPatternBase): + return frozenset() + return pattern.persistence_skip_paths() + + def _native_snapshot_detach_cleanup_paths(self) -> frozenset[Path]: + pattern = getattr(self.mount_strategy, "pattern", None) + if not isinstance(pattern, MountPatternBase): + return frozenset() + return pattern.native_snapshot_detach_cleanup_paths() + def _clear_configured_credentials(self) -> None: """Clear credential-bearing configuration on an internal manifest copy.""" @@ -449,13 +1247,40 @@ def _clear_configured_credentials(self) -> None: if hasattr(self, field_name): setattr(self, field_name, None) + strategy = self.mount_strategy + strategy_updates: dict[str, object] = { + field_name: None for field_name in strategy._configured_credential_fields() + } + pattern = self._rclone_pattern() if pattern is not None: - cast(Any, self.mount_strategy).pattern = pattern.model_copy( - update={"config_file_path": None} - ) - - def _restore_configured_credentials(self, trusted_mount: Mount) -> None: + strategy_updates["pattern"] = pattern.model_copy(update={"config_file_path": None}) + + ( + all_credential_fields, + _, + all_credential_driver_option_names, + ) = _all_registered_mount_credential_metadata() + if isinstance(strategy, DockerVolumeMountStrategy): + strategy_updates["driver_options"] = { + name: value + for name, value in strategy.driver_options.items() + if _rclone_driver_option_credential_kind( + name, + value, + credential_field_names=all_credential_fields, + credential_driver_option_names=all_credential_driver_option_names, + ) + == "none" + } + if strategy_updates: + cast(Any, self).mount_strategy = strategy.model_copy(update=strategy_updates) + + def _restore_configured_credentials( + self, + trusted_mount: Mount, + credential_fields: tuple[str, ...], + ) -> None: """Restore credential-bearing fields on an internal manifest copy.""" if type(self) is not type(trusted_mount): @@ -463,19 +1288,74 @@ def _restore_configured_credentials(self, trusted_mount: Mount) -> None: if type(self.mount_strategy) is not type(trusted_mount.mount_strategy): raise ValueError("Persisted mount credential rebind requires a matching mount strategy") + configured_fields = frozenset(credential_fields) + if "mount.raw_credential" in configured_fields: + raise ValueError( + "Persisted raw mount credentials require a supported typed representation" + ) + trusted_raw_fields = frozenset(trusted_mount._configured_credential_fields()) + trusted_configured_fields = frozenset( + ( + "mount_strategy.driver_options" + if field_name.startswith("mount_strategy.driver_options.") + else "mount_strategy.credential" + if field_name.startswith("mount_strategy.") + and field_name != "mount_strategy.pattern.config_file_path" + else field_name + ) + for field_name in trusted_raw_fields + ) + if configured_fields != trusted_configured_fields: + raise ValueError( + "Sandbox session state mount credentials do not match current trusted configuration" + ) + for field_name in self._credential_field_names: - if hasattr(self, field_name): + if field_name in configured_fields and hasattr(self, field_name): setattr(self, field_name, getattr(trusted_mount, field_name)) rebound_pattern = self._rclone_pattern() trusted_pattern = trusted_mount._rclone_pattern() if (rebound_pattern is None) != (trusted_pattern is None): raise ValueError("Persisted mount credential rebind requires a matching mount pattern") - if rebound_pattern is not None and trusted_pattern is not None: + if ( + "mount_strategy.pattern.config_file_path" in configured_fields + and rebound_pattern is not None + and trusted_pattern is not None + ): cast(Any, self.mount_strategy).pattern = rebound_pattern.model_copy( update={"config_file_path": trusted_pattern.config_file_path} ) + rebound_strategy = self.mount_strategy + trusted_strategy = trusted_mount.mount_strategy + if "mount_strategy.credential" in configured_fields: + strategy_credential_updates = { + field_name: getattr(trusted_strategy, field_name) + for field_name in trusted_strategy._configured_credential_fields() + } + cast(Any, self).mount_strategy = rebound_strategy.model_copy( + update=strategy_credential_updates + ) + rebound_strategy = self.mount_strategy + if isinstance(rebound_strategy, DockerVolumeMountStrategy) and isinstance( + trusted_strategy, DockerVolumeMountStrategy + ): + driver_options = dict(rebound_strategy.driver_options) + if "mount_strategy.driver_options" in configured_fields: + driver_options.update( + { + name: value + for name, value in trusted_strategy.driver_options.items() + if "mount_strategy.driver_options." + f"{_normalize_driver_option_name(name)}" in trusted_raw_fields + } + ) + cast(Any, self).mount_strategy = rebound_strategy.model_copy( + update={"driver_options": driver_options} + ) + + @_data_redacted_async_boundary async def apply( self, session: BaseSandboxSession, @@ -488,8 +1368,10 @@ async def apply( intentionally no-ops because the backend attaches them before the session starts. """ + self._validate_credential_boundary_for_session(session) return await self.mount_strategy.activate(self, session, dest, base_dir) + @_data_redacted_async_boundary async def unmount( self, session: BaseSandboxSession, @@ -498,8 +1380,18 @@ async def unmount( ) -> None: """Deactivate this mount for manifest teardown.""" + self._validate_credential_boundary_for_session(session) await self.mount_strategy.deactivate(self, session, dest, base_dir) + def _validate_credential_boundary_for_session(self, session: BaseSandboxSession) -> None: + from ..._mount_security import validate_mount_credential_boundary_for_session + + validate_mount_credential_boundary_for_session( + self, + session.state.manifest, + session.state._mount_credential_environment_names(), + ) + async def build_in_container_mount_config( self, session: BaseSandboxSession, diff --git a/src/agents/sandbox/entries/mounts/patterns.py b/src/agents/sandbox/entries/mounts/patterns.py index 07803752a6..69305874b7 100644 --- a/src/agents/sandbox/entries/mounts/patterns.py +++ b/src/agents/sandbox/entries/mounts/patterns.py @@ -1,6 +1,7 @@ from __future__ import annotations import abc +import asyncio import hashlib import io import re @@ -8,10 +9,11 @@ import warnings from dataclasses import dataclass, field from pathlib import Path -from typing import TYPE_CHECKING, Annotated, Literal, TypeVar +from typing import TYPE_CHECKING, Annotated, ClassVar, Literal, TypeVar from pydantic import BaseModel, Field +from ....exceptions import _data_redacted_async_boundary from ...errors import ( MountCommandError, MountConfigError, @@ -79,6 +81,9 @@ class S3FilesMountConfig: FuseMountConfig | MountpointMountConfig | RcloneMountConfig | S3FilesMountConfig ) MountPatternConfigT = TypeVar("MountPatternConfigT", bound=MountPatternConfig) +_RCLONE_REDACTED_STDERR = ( + "rclone command failed; stderr omitted because mount configuration may contain credentials" +) def _require_mount_config( @@ -103,9 +108,53 @@ async def _write_sensitive_config_file( ) -> None: """Write generated mount credentials/config with owner-only permissions.""" - await session.write(path, io.BytesIO(payload)) + cancellation_requested = False + try: + await session.write(path, io.BytesIO(payload)) + await session._exec_checked_nonzero( + "chmod", "0600", sandbox_path_str(session.normalize_path(path)) + ) + return + except asyncio.CancelledError: + cancellation_requested = True + except Exception: + pass + + payload = b"" + cleanup_confirmed = True + try: + await _remove_sensitive_config_files(session, path) + except asyncio.CancelledError: + cancellation_requested = True + cleanup_confirmed = False + except Exception: + cleanup_confirmed = False + + if cancellation_requested and cleanup_confirmed: + raise asyncio.CancelledError() from None + raise MountCommandError( + command="write sensitive mount configuration", + stderr=None, + context={ + "stage": "write_or_permissions", + "cleanup_confirmed": cleanup_confirmed, + "cancellation_requested": cancellation_requested, + }, + ) from None + + +async def _remove_sensitive_config_files( + session: BaseSandboxSession, + *paths: Path, +) -> None: + """Remove generated mount credential/config files and surface cleanup failures.""" + + if not paths: + return await session._exec_checked_nonzero( - "chmod", "0600", sandbox_path_str(session.normalize_path(path)) + "rm", + "-f", + *(sandbox_path_str(session.normalize_path(path)) for path in paths), ) @@ -116,13 +165,14 @@ def _render_shell_exports(env_vars: list[tuple[str, str]]) -> bytes: def _redact_sensitive_values(text: str, sensitive_values: list[str]) -> str: redacted = text - for value in sensitive_values: - if not value: - continue - redacted = redacted.replace(value, "REDACTED") - quoted = shlex.quote(value) - if quoted != value: - redacted = redacted.replace(quoted, "REDACTED") + representations = { + representation + for value in sensitive_values + if value + for representation in (value, shlex.quote(value)) + } + for representation in sorted(representations, key=len, reverse=True): + redacted = redacted.replace(representation, "REDACTED") return redacted @@ -145,6 +195,15 @@ async def _read_text_if_present(session: BaseSandboxSession, path: Path) -> str: class MountPatternBase(BaseModel, abc.ABC): + _persistence_skip_paths: ClassVar[frozenset[Path]] = frozenset() + _native_snapshot_detach_cleanup_paths: ClassVar[frozenset[Path]] = frozenset() + + def persistence_skip_paths(self) -> frozenset[Path]: + return self._persistence_skip_paths + + def native_snapshot_detach_cleanup_paths(self) -> frozenset[Path]: + return self._native_snapshot_detach_cleanup_paths + @abc.abstractmethod async def apply( self, @@ -166,6 +225,12 @@ async def unapply( class FuseMountPattern(MountPatternBase): type: Literal["fuse"] = "fuse" + _persistence_skip_paths: ClassVar[frozenset[Path]] = frozenset( + {Path(".sandbox-blobfuse-cache"), Path(".sandbox-blobfuse-config")} + ) + _native_snapshot_detach_cleanup_paths: ClassVar[frozenset[Path]] = frozenset( + {Path(".sandbox-blobfuse-config")} + ) allow_other: bool = Field(default=True) log_type: str = Field(default="syslog") log_level: str = Field(default="log_debug") @@ -186,6 +251,10 @@ class FuseMountPattern(MountPatternBase): entry_cache_timeout_sec: int | None = None negative_entry_cache_timeout_sec: int | None = None + def persistence_skip_paths(self) -> frozenset[Path]: + cache_path = self.cache_path or Path(".sandbox-blobfuse-cache") + return frozenset({cache_path, Path(".sandbox-blobfuse-config")}) + def model_post_init(self, __context: object, /) -> None: if self.cache_path is None: return @@ -306,6 +375,7 @@ def to_text(self) -> str: lines.append("") return "\n".join(lines) + @_data_redacted_async_boundary async def apply( self, session: BaseSandboxSession, @@ -392,21 +462,28 @@ async def apply( account_key=fuse_config.account_key, ) config_payload = blobfuse_config.to_text().encode("utf-8") - await _write_sensitive_config_file(session, config_path, config_payload) + try: + await _write_sensitive_config_file(session, config_path, config_payload) - cmd: list[str] = ["blobfuse2", "mount"] - if fuse_config.read_only: - cmd.append("--read-only") - cmd.extend(["--config-file", sandbox_path_str(command_config_path)]) - cmd.append(sandbox_path_str(mount_path)) + cmd: list[str] = ["blobfuse2", "mount"] + if fuse_config.read_only: + cmd.append("--read-only") + cmd.extend(["--config-file", sandbox_path_str(command_config_path)]) + cmd.append(sandbox_path_str(mount_path)) - result = await session.exec(*cmd, shell=False) - if not result.ok(): - raise MountCommandError( - command=" ".join(cmd), - stderr=result.stderr.decode("utf-8", errors="replace"), - context={"account": account, "container": container}, - ) + result = await session.exec(*cmd, shell=False) + if not result.ok(): + raise MountCommandError( + command=" ".join(cmd), + stderr=_redact_sensitive_values( + result.stderr.decode("utf-8", errors="replace"), + [fuse_config.account_key or ""], + ), + context={"account": account, "container": container}, + ) + except BaseException: + await _remove_sensitive_config_files(session, config_path) + raise async def unapply( self, @@ -414,19 +491,36 @@ async def unapply( path: Path, config: MountPatternConfig, ) -> None: - _ = _require_mount_config(config, FuseMountConfig) - # Best-effort unmount; ignore failures for already-unmounted mounts. - await session.exec( - "sh", - "-lc", - f"fusermount3 -u {shlex.quote(sandbox_path_str(path))} || " - f"umount {shlex.quote(sandbox_path_str(path))}", - shell=False, + fuse_config = _require_mount_config(config, FuseMountConfig) + session_id = getattr(session.state, "session_id", None) + if session_id is None: + raise MountConfigError( + message="mount session is missing session_id", + context={"type": fuse_config.mount_type}, + ) + config_name = f"{fuse_config.account}_{fuse_config.container}".replace("/", "_") + config_path = posix_path_as_path( + coerce_posix_path(f".sandbox-blobfuse-config/{session_id.hex}/{config_name}.yaml") ) + try: + # Best-effort unmount; ignore failures for already-unmounted mounts. + await session.exec( + "sh", + "-lc", + f"fusermount3 -u {shlex.quote(sandbox_path_str(path))} || " + f"umount {shlex.quote(sandbox_path_str(path))}", + shell=False, + ) + finally: + await _remove_sensitive_config_files(session, config_path) class MountpointMountPattern(MountPatternBase): type: Literal["mountpoint"] = "mountpoint" + _persistence_skip_paths: ClassVar[frozenset[Path]] = frozenset( + {Path(".sandbox-mountpoint-env")} + ) + _native_snapshot_detach_cleanup_paths: ClassVar[frozenset[Path]] = _persistence_skip_paths @dataclass(frozen=True) class MountpointOptions: @@ -436,6 +530,33 @@ class MountpointOptions: options: MountpointOptions = Field(default_factory=MountpointOptions) + @staticmethod + def _runtime_paths( + session: BaseSandboxSession, + path: Path, + config: MountpointMountConfig, + ) -> tuple[Path, Path, Path] | None: + if not (config.access_key_id and config.secret_access_key): + return None + session_id = getattr(session.state, "session_id", None) + if session_id is None: + raise MountConfigError( + message="mount session is missing session_id", + context={"type": config.mount_type}, + ) + command_hash = hashlib.sha256( + f"{config.bucket}\0{sandbox_path_str(path)}".encode() + ).hexdigest()[:16] + config_dir = posix_path_as_path( + coerce_posix_path(f".sandbox-mountpoint-env/{session_id.hex}") + ) + return ( + config_dir / f"{command_hash}.env", + config_dir / f"{command_hash}.stdout", + config_dir / f"{command_hash}.stderr", + ) + + @_data_redacted_async_boundary async def apply( self, session: BaseSandboxSession, @@ -482,24 +603,12 @@ async def apply( env_vars.append(("AWS_SESSION_TOKEN", session_token)) joined_cmd = " ".join(shlex.quote(part) for part in cmd) + runtime_paths = self._runtime_paths(session, path, mountpoint_config) stderr_path: Path | None = None sensitive_values = [value for _name, value in env_vars] - if env_vars: - session_id = getattr(session.state, "session_id", None) - if session_id is None: - raise MountConfigError( - message="mount session is missing session_id", - context={"type": mountpoint_config.mount_type}, - ) - command_hash = hashlib.sha256( - f"{bucket}\0{sandbox_path_str(path)}".encode() - ).hexdigest()[:16] - config_dir = posix_path_as_path( - coerce_posix_path(f".sandbox-mountpoint-env/{session_id.hex}") - ) - env_path = config_dir / f"{command_hash}.env" - stdout_path = config_dir / f"{command_hash}.stdout" - stderr_path = config_dir / f"{command_hash}.stderr" + if runtime_paths is not None: + env_path, stdout_path, stderr_path = runtime_paths + config_dir = env_path.parent await session.mkdir(config_dir, parents=True) session.register_persist_workspace_skip_path(config_dir) @@ -513,17 +622,22 @@ async def apply( f">{shlex.quote(command_stdout_path)} 2>{shlex.quote(command_stderr_path)}" ) - result = await session.exec("sh", "-lc", joined_cmd, shell=False) - if not result.ok(): - stderr = result.stderr.decode("utf-8", errors="replace") - if stderr_path is not None: - stderr += await _read_text_if_present(session, stderr_path) - stderr = _redact_sensitive_values(stderr, sensitive_values) - raise MountCommandError( - command=joined_cmd, - stderr=stderr, - context={"bucket": bucket}, - ) + try: + result = await session.exec("sh", "-lc", joined_cmd, shell=False) + if not result.ok(): + stderr = result.stderr.decode("utf-8", errors="replace") + if stderr_path is not None: + stderr += await _read_text_if_present(session, stderr_path) + stderr = _redact_sensitive_values(stderr, sensitive_values) + raise MountCommandError( + command=joined_cmd, + stderr=stderr, + context={"bucket": bucket}, + ) + except BaseException: + if runtime_paths is not None: + await _remove_sensitive_config_files(session, *runtime_paths) + raise async def unapply( self, @@ -531,14 +645,19 @@ async def unapply( path: Path, config: MountPatternConfig, ) -> None: - _ = _require_mount_config(config, MountpointMountConfig) - await session.exec( - "sh", - "-lc", - f"fusermount3 -u {shlex.quote(sandbox_path_str(path))} || " - f"umount {shlex.quote(sandbox_path_str(path))}", - shell=False, - ) + mountpoint_config = _require_mount_config(config, MountpointMountConfig) + runtime_paths = self._runtime_paths(session, path, mountpoint_config) + try: + await session.exec( + "sh", + "-lc", + f"fusermount3 -u {shlex.quote(sandbox_path_str(path))} || " + f"umount {shlex.quote(sandbox_path_str(path))}", + shell=False, + ) + finally: + if runtime_paths is not None: + await _remove_sensitive_config_files(session, *runtime_paths) class S3FilesMountPattern(MountPatternBase): @@ -553,6 +672,7 @@ class S3FilesOptions: options: S3FilesOptions = Field(default_factory=S3FilesOptions) + @_data_redacted_async_boundary async def apply( self, session: BaseSandboxSession, @@ -649,6 +769,8 @@ def _supplement_rclone_config_text( class RcloneMountPattern(MountPatternBase): type: Literal["rclone"] = "rclone" + _persistence_skip_paths: ClassVar[frozenset[Path]] = frozenset({Path(".sandbox-rclone-config")}) + _native_snapshot_detach_cleanup_paths: ClassVar[frozenset[Path]] = _persistence_skip_paths mode: Literal["fuse", "nfs"] = Field(default="fuse") remote_name: str | None = None extra_args: list[str] = Field(default_factory=list) @@ -656,6 +778,22 @@ class RcloneMountPattern(MountPatternBase): nfs_mount_options: list[str] | None = None config_file_path: Path | None = None + @staticmethod + def _runtime_config_path( + session: BaseSandboxSession, + config: RcloneMountConfig, + ) -> Path: + session_id = getattr(session.state, "session_id", None) + if session_id is None: + raise MountConfigError( + message="mount session is missing session_id", + context={"type": config.mount_type}, + ) + config_dir = posix_path_as_path( + coerce_posix_path(f".sandbox-rclone-config/{session_id.hex}") + ) + return config_dir / f"{config.remote_name}.conf" + def resolve_remote_name( self, *, @@ -786,7 +924,7 @@ async def _start_rclone_server( if not result.ok(): raise MountCommandError( command=" ".join(cmd), - stderr=result.stderr.decode("utf-8", errors="replace"), + stderr=_RCLONE_REDACTED_STDERR, context={"type": config.mount_type}, ) @@ -815,7 +953,7 @@ async def _start_rclone_client( if not result.ok(): raise MountCommandError( command=" ".join(cmd), - stderr=result.stderr.decode("utf-8", errors="replace"), + stderr=_RCLONE_REDACTED_STDERR, context={"type": config.mount_type}, ) return @@ -883,10 +1021,11 @@ async def _start_rclone_client( if not mount_result.ok(): raise MountCommandError( command=" ".join(mount_cmd), - stderr=mount_result.stderr.decode("utf-8", errors="replace"), + stderr=_RCLONE_REDACTED_STDERR, context={"type": config.mount_type}, ) + @_data_redacted_async_boundary async def apply( self, session: BaseSandboxSession, @@ -912,54 +1051,49 @@ async def apply( context={"type": rclone_config.mount_type}, ) - session_id = getattr(session.state, "session_id", None) - if session_id is None: - raise MountConfigError( - message="mount session is missing session_id", - context={"type": rclone_config.mount_type}, - ) - session_id_str = session_id.hex # Keep generated rclone config under the workspace root so `session.mkdir()` / # `session.write()` can handle it without special-casing absolute paths. - config_dir = posix_path_as_path( - coerce_posix_path(f".sandbox-rclone-config/{session_id_str}") - ) - config_path = config_dir / f"{rclone_config.remote_name}.conf" + config_path = self._runtime_config_path(session, rclone_config) + config_dir = config_path.parent await session.mkdir(path, parents=True) await session.mkdir(config_dir, parents=True) session.register_persist_workspace_skip_path(config_dir) # Always write an isolated config file for the live mount operation so provider-specific # augmentation does not mutate a shared source config in the workspace. - await _write_sensitive_config_file( - session, - config_path, - rclone_config.config_text.encode("utf-8"), - ) - command_config_path = session.normalize_path(config_path) - - if self.mode == "nfs": - nfs_addr = self.nfs_addr or "127.0.0.1:2049" - await self._start_rclone_server( - session, - config=rclone_config, - config_path=command_config_path, - nfs_addr=nfs_addr, - ) - await self._start_rclone_client( - session, - path=path, - config=rclone_config, - config_path=command_config_path, - nfs_addr=nfs_addr, - ) - else: - # fuse mode - await self._start_rclone_client( + try: + await _write_sensitive_config_file( session, - path=path, - config=rclone_config, - config_path=command_config_path, + config_path, + rclone_config.config_text.encode("utf-8"), ) + command_config_path = session.normalize_path(config_path) + + if self.mode == "nfs": + nfs_addr = self.nfs_addr or "127.0.0.1:2049" + await self._start_rclone_server( + session, + config=rclone_config, + config_path=command_config_path, + nfs_addr=nfs_addr, + ) + await self._start_rclone_client( + session, + path=path, + config=rclone_config, + config_path=command_config_path, + nfs_addr=nfs_addr, + ) + else: + # fuse mode + await self._start_rclone_client( + session, + path=path, + config=rclone_config, + config_path=command_config_path, + ) + except BaseException: + await _remove_sensitive_config_files(session, config_path) + raise async def unapply( self, @@ -968,31 +1102,36 @@ async def unapply( config: MountPatternConfig, ) -> None: rclone_config = _require_mount_config(config, RcloneMountConfig) - if self.mode == "fuse": - await session.exec( - "sh", - "-lc", - f"fusermount3 -u {shlex.quote(sandbox_path_str(path))} || " - f"umount {shlex.quote(sandbox_path_str(path))}", - shell=False, - ) - if self.mode == "nfs": + config_path = self._runtime_config_path(session, rclone_config) + try: + if self.mode == "fuse": + await session.exec( + "sh", + "-lc", + f"fusermount3 -u {shlex.quote(sandbox_path_str(path))} || " + f"umount {shlex.quote(sandbox_path_str(path))}", + shell=False, + ) + if self.mode == "nfs": + await session.exec( + "sh", + "-lc", + f"umount {shlex.quote(sandbox_path_str(path))} >/dev/null 2>&1 || true", + shell=False, + ) + await session.exec( "sh", "-lc", - f"umount {shlex.quote(sandbox_path_str(path))} >/dev/null 2>&1 || true", + ( + "pkill -f -- " + f"'rclone (mount|serve nfs) {rclone_config.remote_name}:' " + ">/dev/null 2>&1 || true" + ), shell=False, ) - - await session.exec( - "sh", - "-lc", - ( - "pkill -f -- " - f"'rclone (mount|serve nfs) {rclone_config.remote_name}:' >/dev/null 2>&1 || true" - ), - shell=False, - ) + finally: + await _remove_sensitive_config_files(session, config_path) MountPattern = Annotated[ diff --git a/src/agents/sandbox/entries/mounts/providers/azure_blob.py b/src/agents/sandbox/entries/mounts/providers/azure_blob.py index e5b822f708..a67b137190 100644 --- a/src/agents/sandbox/entries/mounts/providers/azure_blob.py +++ b/src/agents/sandbox/entries/mounts/providers/azure_blob.py @@ -21,6 +21,23 @@ class AzureBlobMount(_ConfiguredMount): type: Literal["azure_blob_mount"] = "azure_blob_mount" _credential_field_names: ClassVar[frozenset[str]] = frozenset({"account_key"}) + _ambient_credential_reference_field_names: ClassVar[frozenset[str]] = frozenset( + {"identity_client_id"} + ) + _url_field_names: ClassVar[frozenset[str]] = frozenset({"endpoint"}) + _credential_driver_option_names: ClassVar[frozenset[str]] = frozenset( + { + "azureblob-client-certificate-pem", + "azureblob-client-certificate-password", + "azureblob-client-certificate-path", + "azureblob-client-secret", + "azureblob-connection-string", + "azureblob-key", + "azureblob-password", + "azureblob-sas-url", + "azureblob-service-principal-file", + } + ) account: str # AZURE_STORAGE_ACCOUNT container: str # AZURE_STORAGE_CONTAINER endpoint: str | None = None @@ -98,7 +115,5 @@ def _rclone_required_lines(self, remote_name: str) -> list[str]: if self.account_key: lines.append(f"key = {self.account_key}") else: - lines.append("use_msi = true") - if self.identity_client_id: - lines.append(f"msi_client_id = {self.identity_client_id}") + lines.append("use_msi = false") return lines diff --git a/src/agents/sandbox/entries/mounts/providers/box.py b/src/agents/sandbox/entries/mounts/providers/box.py index 2b4b356908..6e552b7fb4 100644 --- a/src/agents/sandbox/entries/mounts/providers/box.py +++ b/src/agents/sandbox/entries/mounts/providers/box.py @@ -30,6 +30,7 @@ class BoxMount(_ConfiguredMount): "config_credentials", } ) + _credential_file_field_names: ClassVar[frozenset[str]] = frozenset({"box_config_file"}) path: str | None = None client_id: str | None = None client_secret: str | None = None diff --git a/src/agents/sandbox/entries/mounts/providers/gcs.py b/src/agents/sandbox/entries/mounts/providers/gcs.py index 688489b081..47756426d2 100644 --- a/src/agents/sandbox/entries/mounts/providers/gcs.py +++ b/src/agents/sandbox/entries/mounts/providers/gcs.py @@ -29,6 +29,8 @@ class GCSMount(_ConfiguredMount): "access_token", } ) + _credential_file_field_names: ClassVar[frozenset[str]] = frozenset({"service_account_file"}) + _url_field_names: ClassVar[frozenset[str]] = frozenset({"endpoint_url"}) bucket: str access_id: str | None = None secret_access_key: str | None = None @@ -180,7 +182,8 @@ def _rclone_required_lines(self, remote_name: str) -> list[str]: and self.service_account_credentials is None and self.access_token is None ): - lines.append("env_auth = true") + lines.append("env_auth = false") + lines.append("anonymous = true") else: lines.append("env_auth = false") return lines diff --git a/src/agents/sandbox/entries/mounts/providers/r2.py b/src/agents/sandbox/entries/mounts/providers/r2.py index 00cd73009d..7f936bf94e 100644 --- a/src/agents/sandbox/entries/mounts/providers/r2.py +++ b/src/agents/sandbox/entries/mounts/providers/r2.py @@ -17,6 +17,7 @@ class R2Mount(_ConfiguredMount): _credential_field_names: ClassVar[frozenset[str]] = frozenset( {"access_key_id", "secret_access_key"} ) + _url_field_names: ClassVar[frozenset[str]] = frozenset({"custom_domain"}) bucket: str account_id: str access_key_id: str | None = None @@ -99,5 +100,5 @@ def _rclone_required_lines(self, remote_name: str) -> list[str]: lines.append(f"access_key_id = {self.access_key_id}") lines.append(f"secret_access_key = {self.secret_access_key}") else: - lines.append("env_auth = true") + lines.append("env_auth = false") return lines diff --git a/src/agents/sandbox/entries/mounts/providers/s3.py b/src/agents/sandbox/entries/mounts/providers/s3.py index 78e979a29e..18d7ad9763 100644 --- a/src/agents/sandbox/entries/mounts/providers/s3.py +++ b/src/agents/sandbox/entries/mounts/providers/s3.py @@ -4,7 +4,7 @@ from typing import TYPE_CHECKING, ClassVar, Literal from ....errors import MountConfigError -from ..base import DockerVolumeMountStrategy +from ..base import _RCLONE_S3_CREDENTIAL_DRIVER_OPTION_NAMES, DockerVolumeMountStrategy from ..patterns import ( MountPattern, MountPatternConfig, @@ -23,6 +23,10 @@ class S3Mount(_ConfiguredMount): _credential_field_names: ClassVar[frozenset[str]] = frozenset( {"access_key_id", "secret_access_key", "session_token"} ) + _credential_driver_option_names: ClassVar[frozenset[str]] = ( + _RCLONE_S3_CREDENTIAL_DRIVER_OPTION_NAMES + ) + _url_field_names: ClassVar[frozenset[str]] = frozenset({"endpoint_url"}) bucket: str access_key_id: str | None = None secret_access_key: str | None = None @@ -132,5 +136,5 @@ def _rclone_required_lines(self, remote_name: str) -> list[str]: if self.session_token: lines.append(f"session_token = {self.session_token}") else: - lines.append("env_auth = true") + lines.append("env_auth = false") return lines diff --git a/src/agents/sandbox/entries/mounts/providers/s3_files.py b/src/agents/sandbox/entries/mounts/providers/s3_files.py index da0d7c3605..a4cd07d30f 100644 --- a/src/agents/sandbox/entries/mounts/providers/s3_files.py +++ b/src/agents/sandbox/entries/mounts/providers/s3_files.py @@ -6,6 +6,7 @@ from pydantic import Field from ....errors import MountConfigError +from ..base import _configured_option_map_credential_fields from ..patterns import ( MountPattern, MountPatternConfig, @@ -45,6 +46,19 @@ class S3FilesMount(_ConfiguredMount): region: str | None = None extra_options: dict[str, str | None] = Field(default_factory=dict) + def _configured_credential_fields(self) -> tuple[str, ...]: + return tuple( + dict.fromkeys( + ( + *super()._configured_credential_fields(), + *_configured_option_map_credential_fields( + self.extra_options, + field_prefix="extra_options", + ), + ) + ) + ) + def supported_in_container_patterns(self) -> tuple[builtins.type[MountPattern], ...]: return (S3FilesMountPattern,) diff --git a/src/agents/sandbox/manifest.py b/src/agents/sandbox/manifest.py index a59bbd24ae..25f7185b59 100644 --- a/src/agents/sandbox/manifest.py +++ b/src/agents/sandbox/manifest.py @@ -7,7 +7,6 @@ from pydantic import ( BaseModel, Field, - PrivateAttr, SerializeAsAny, field_serializer, field_validator, @@ -16,9 +15,10 @@ from typing_extensions import assert_never from .._config_coercion import coerce_pydantic_config +from ..exceptions import _data_redacted_boundary from ..util._asyncio_tasks import gather_with_cancel -from .entries import BaseEntry, Dir, Mount, resolve_workspace_path -from .errors import InvalidManifestPathError +from .entries import BaseEntry, Dir, File, Mount, resolve_workspace_path +from .errors import InvalidManifestPathError, MountConfigError from .manifest_render import render_manifest_description from .types import Group, User from .workspace_paths import ( @@ -226,56 +226,6 @@ class Manifest(BaseModel): remote_mount_command_allowlist: list[str] = Field( default_factory=lambda: list(DEFAULT_REMOTE_MOUNT_COMMAND_ALLOWLIST) ) - _in_container_mount_credential_exposure_allowed_paths: frozenset[str] = PrivateAttr( - default_factory=frozenset - ) - - def with_in_container_mount_credential_exposure_allowed( - self, - *mount_paths: str | PurePath, - ) -> "Manifest": - """Return a trusted manifest that acknowledges credential exposure for mount paths. - - This application-side policy is intentionally private runtime state. It is not accepted - from manifest dictionaries and is never serialized into sandbox or RunState state. - """ - - if not mount_paths: - raise ValueError("At least one in-container mount path must be provided") - - root = posix_path_as_path(coerce_posix_path(self.root)) - allowed = set(self._in_container_mount_credential_exposure_allowed_paths) - for raw_path in mount_paths: - if (windows_path := windows_absolute_path(raw_path)) is not None: - raise InvalidManifestPathError(rel=windows_path.as_posix(), reason="absolute") - path = posix_path_as_path(coerce_posix_path(raw_path)) - if path.is_absolute(): - normalized = self._normalize_in_workspace_path(root, path) - if normalized is None: - raise InvalidManifestPathError(rel=path.as_posix(), reason="absolute") - path = normalized.relative_to(root) - else: - path = resolve_workspace_path(root, path).relative_to(root) - allowed.add(path.as_posix()) - - trusted = self.model_copy(deep=True) - trusted._in_container_mount_credential_exposure_allowed_paths = frozenset(allowed) - return trusted - - def _allows_in_container_mount_credential_exposure(self, mount_path: Path) -> bool: - root = posix_path_as_path(coerce_posix_path(self.root)) - try: - relative_path = mount_path.relative_to(root) - except ValueError: - return False - return ( - relative_path.as_posix() in self._in_container_mount_credential_exposure_allowed_paths - ) - - def _copy_mount_credential_exposure_policy_from(self, trusted_manifest: "Manifest") -> None: - self._in_container_mount_credential_exposure_allowed_paths = frozenset( - trusted_manifest._in_container_mount_credential_exposure_allowed_paths - ) @field_validator("entries", mode="before") @classmethod @@ -322,8 +272,28 @@ def mount_targets(self) -> list[tuple[Mount, Path]]: def ephemeral_mount_targets(self) -> list[tuple[Mount, Path]]: return [(artifact, path) for artifact, path in self.mount_targets() if artifact.ephemeral] + def _reject_serialized_files_with_opaque_credential_authorities(self) -> None: + if not any(isinstance(entry, File) for _path, entry in self.iter_entries()): + return + for mount, mount_path in self.mount_targets(): + if not mount._configured_opaque_credential_authority_options(): + continue + raise MountConfigError( + message=( + "opaque mount credential commands or connection strings cannot be combined " + "with serialized manifest files; use flat structured options or external " + "file provisioning" + ), + context={ + "mount_path": mount_path.as_posix(), + "mount_type": mount.type, + "mount_strategy": mount.mount_strategy.type, + }, + ) + def ephemeral_persistence_paths(self, depth: int | None = 1) -> set[Path]: _ = depth + self._reject_serialized_files_with_opaque_credential_authorities() root = posix_path_as_path(coerce_posix_path(self.root)) skip = self.ephemeral_entry_paths(depth=depth) for _mount, mount_path in self.ephemeral_mount_targets(): @@ -333,6 +303,21 @@ def ephemeral_persistence_paths(self, depth: int | None = 1) -> set[Path]: continue if rel_mount_path.parts: skip.add(rel_mount_path) + for mount, _mount_path in self.mount_targets(): + skip.update(mount._internal_persistence_paths()) + for _field_name, credential_path in mount._configured_credential_file_paths(): + resolved_path = ( + credential_path if credential_path.is_absolute() else root / credential_path + ) + try: + normalized_path = self._normalize_in_workspace_path(root, resolved_path) + except InvalidManifestPathError: + continue + if normalized_path is None: + continue + rel_credential_path = normalized_path.relative_to(root) + if rel_credential_path.parts: + skip.add(rel_credential_path) return skip @staticmethod @@ -439,6 +424,7 @@ def describe(self, depth: int | None = 1) -> str: ) +@_data_redacted_boundary def _coerce_manifest(value: Manifest | dict[str, Any], *, parameter_name: str) -> Manifest: """Normalize manifest dictionaries without granting untrusted host filesystem access.""" if isinstance(value, dict) and any( @@ -448,10 +434,7 @@ def _coerce_manifest(value: Manifest | dict[str, Any], *, parameter_name: str) - "_in_container_mount_credential_exposure_allowed_paths", ) ): - raise TypeError( - f"{parameter_name} in-container mount credential exposure must be configured " - "on a trusted Manifest instance, not in a dictionary" - ) + raise TypeError(f"{parameter_name} in-container mount credential exposure is not supported") if isinstance(value, dict) and "extra_path_grants" in value: extra_path_grants = value["extra_path_grants"] if not isinstance(extra_path_grants, list | tuple) or extra_path_grants: diff --git a/src/agents/sandbox/runtime_session_manager.py b/src/agents/sandbox/runtime_session_manager.py index aebe860c89..6323aaf708 100644 --- a/src/agents/sandbox/runtime_session_manager.py +++ b/src/agents/sandbox/runtime_session_manager.py @@ -9,6 +9,7 @@ from typing import Any, Generic, cast from ..agent import Agent +from ..exceptions import _data_redacted_async_boundary from ..run_config import SandboxArchiveLimits, SandboxConcurrencyLimits, SandboxRunConfig from ..run_context import TContext from ..run_state import ( @@ -17,6 +18,10 @@ _build_agent_identity_keys_by_id, ) from ..tracing import custom_span, get_current_trace +from ._mount_security import ( + validate_mount_credential_boundaries, + validate_mount_credential_environment_names, +) from .capabilities import Capability from .entries import BaseEntry, Dir, Mount, resolve_workspace_path from .manifest import Manifest @@ -194,6 +199,7 @@ def acquire_agent(self, agent: SandboxAgent[TContext]) -> None: self._acquired_agents[agent_id] = agent self._ensure_resume_key(agent) + @_data_redacted_async_boundary async def ensure_session( self, *, @@ -288,6 +294,27 @@ async def _create_resources( concurrency_limits = self._resolve_concurrency_limits() archive_limits = self._resolve_archive_limits() if sandbox_config.session is not None: + current_manifest = sandbox_config.session.state.manifest + processed_manifest = self._process_manifest( + capabilities, + current_manifest, + run_as_user=self._agent_run_as_user(agent), + ) + candidate_manifest = processed_manifest or current_manifest + credential_environment_names = ( + sandbox_config.session.state._mount_credential_environment_names() + ) + validate_mount_credential_boundaries(current_manifest) + validate_mount_credential_environment_names( + current_manifest, + credential_environment_names, + ) + if candidate_manifest != current_manifest: + validate_mount_credential_boundaries(candidate_manifest) + validate_mount_credential_environment_names( + candidate_manifest, + credential_environment_names, + ) self._configure_session( sandbox_config.session, concurrency_limits=concurrency_limits, @@ -295,9 +322,8 @@ async def _create_resources( ) running = await sandbox_config.session.running() manifest_update = self._process_live_session_manifest( - agent=agent, - capabilities=capabilities, - session=sandbox_config.session, + current_manifest=current_manifest, + processed_manifest=candidate_manifest, running=running, ) if manifest_update.processed_manifest is not None: @@ -553,18 +579,11 @@ def _process_manifest( def _process_live_session_manifest( cls, *, - agent: SandboxAgent[TContext], - capabilities: list[Capability], - session: BaseSandboxSession, + current_manifest: Manifest, + processed_manifest: Manifest, running: bool, ) -> _LiveSessionManifestUpdate: - current_manifest = session.state.manifest - processed_manifest = cls._process_manifest( - capabilities, - current_manifest, - run_as_user=cls._agent_run_as_user(agent), - ) - if processed_manifest is None or processed_manifest == current_manifest: + if processed_manifest == current_manifest: return _LiveSessionManifestUpdate(processed_manifest=None, entries_to_apply=[]) cls._validate_live_session_host_path_grants( @@ -783,12 +802,13 @@ def _process_resumed_state_manifest( ) if trusted_resume_manifest is None: return rebound_state - trusted_resume_manifest = trusted_resume_manifest.model_copy( - update={"root": rebound_state.manifest.root}, - deep=True, + processed_manifest = cls._manifest_with_run_as_user( + rebound_state.manifest, + cls._agent_run_as_user(agent), ) - processed_state = rebound_state.model_copy(update={"manifest": trusted_resume_manifest}) - return processed_state.rebind_persisted_path_grants(trusted_resume_manifest) + if processed_manifest is not rebound_state.manifest: + rebound_state = rebound_state.model_copy(update={"manifest": processed_manifest}) + return rebound_state.rebind_persisted_path_grants(trusted_resume_manifest) rebound_state = session_state resume_manifest = rebound_state.manifest @@ -800,15 +820,15 @@ def _process_resumed_state_manifest( ) }, ) - processed_manifest = cls._process_manifest( + processed_resume_manifest = cls._process_manifest( capabilities, resume_manifest, run_as_user=cls._agent_run_as_user(agent), ) - if processed_manifest is None: + if processed_resume_manifest is None: return rebound_state - processed_state = rebound_state.model_copy(update={"manifest": processed_manifest}) - return processed_state.rebind_persisted_path_grants(processed_manifest) + processed_state = rebound_state.model_copy(update={"manifest": processed_resume_manifest}) + return processed_state.rebind_persisted_path_grants(processed_resume_manifest) @staticmethod def _agent_run_as_user(agent: SandboxAgent[Any]) -> User | None: diff --git a/src/agents/sandbox/sandboxes/docker.py b/src/agents/sandbox/sandboxes/docker.py index 92031b1aad..d1cff89481 100644 --- a/src/agents/sandbox/sandboxes/docker.py +++ b/src/agents/sandbox/sandboxes/docker.py @@ -26,6 +26,7 @@ from docker.types import DriverConfig, Mount as DockerSDKMount # type: ignore[import-untyped] from docker.utils import parse_repository_tag +from ...exceptions import _data_redacted_async_boundary, _data_redacted_boundary from ..entries import ( Mount, resolve_workspace_path, @@ -38,9 +39,11 @@ S3FilesMountPattern, ) from ..errors import ( + ErrorCode, ExecTimeoutError, ExecTransportError, ExposedPortUnavailableError, + SandboxRuntimeError, WorkspaceArchiveReadError, WorkspaceArchiveWriteError, ) @@ -1460,6 +1463,7 @@ def __init__( self._instrumentation = instrumentation or Instrumentation() self._dependencies = dependencies + @_data_redacted_async_boundary async def create( self, *, @@ -1479,7 +1483,29 @@ async def create( exposed_ports=options.exposed_ports, session_id=session_id, ) - container.start() + start_error_type: str | None = None + try: + container.start() + except Exception as exc: + start_error_type = type(exc).__name__ + if start_error_type is not None: + cleanup_result = self._cleanup_failed_create_resources( + container=container, + volume_names=_docker_volume_names_for_manifest( + manifest, + session_id=session_id, + ), + ) + raise SandboxRuntimeError( + message="failed to start Docker sandbox container", + error_code=ErrorCode.MOUNT_FAILED, + op="start", + context={ + "backend": "docker", + "cause_type": start_error_type, + **cleanup_result, + }, + ) container_id = container.id assert container_id is not None @@ -1532,6 +1558,7 @@ async def delete(self, session: SandboxSession) -> SandboxSession: volume.remove() return session + @_data_redacted_async_boundary async def resume( self, state: SandboxSessionState, @@ -1564,6 +1591,7 @@ async def resume( inner._set_start_state_preserved(reused_existing_container) return self._wrap_session(inner, instrumentation=self._instrumentation) + @_data_redacted_boundary def deserialize_session_state(self, payload: dict[str, object]) -> SandboxSessionState: return self._deserialize_session_state_payload(payload, DockerSandboxSessionState) @@ -1612,7 +1640,67 @@ async def _create_container( create_kwargs["ports"] = { _docker_port_key(port): ("127.0.0.1", None) for port in exposed_ports } - return self.docker_client.containers.create(**create_kwargs) + create_error_type: str | None = None + try: + return self.docker_client.containers.create(**create_kwargs) + except Exception as exc: + create_error_type = type(exc).__name__ + cleanup_result = self._cleanup_failed_create_resources( + container=None, + volume_names=( + _docker_volume_names_for_manifest(manifest, session_id=session_id) + if manifest is not None + else [] + ), + ) + raise SandboxRuntimeError( + message="failed to create Docker sandbox container", + error_code=ErrorCode.MOUNT_FAILED, + op="start", + context={ + "backend": "docker", + "cause_type": create_error_type, + **cleanup_result, + }, + ) + + def _cleanup_failed_create_resources( + self, + *, + container: Container | None, + volume_names: list[str], + ) -> dict[str, bool]: + """Best-effort cleanup without retaining or reflecting backend error payloads.""" + + container_cleanup_confirmed = False + if container is not None: + try: + container.remove(force=True) + except Exception: + pass + else: + container_cleanup_confirmed = True + + volume_cleanup_confirmed = True + for volume_name in volume_names: + try: + volume = self.docker_client.volumes.get(volume_name) + except docker.errors.NotFound: + continue + except Exception: + volume_cleanup_confirmed = False + continue + try: + volume.remove() + except docker.errors.NotFound: + continue + except Exception: + volume_cleanup_confirmed = False + + return { + "container_cleanup_confirmed": container_cleanup_confirmed, + "volume_cleanup_confirmed": volume_cleanup_confirmed, + } def image_exists(self, image: str) -> bool: try: diff --git a/src/agents/sandbox/sandboxes/unix_local.py b/src/agents/sandbox/sandboxes/unix_local.py index 536d28be5b..0768e747e5 100644 --- a/src/agents/sandbox/sandboxes/unix_local.py +++ b/src/agents/sandbox/sandboxes/unix_local.py @@ -28,6 +28,7 @@ from pathlib import Path from typing import Literal, cast +from ...exceptions import _data_redacted_async_boundary, _data_redacted_boundary from ...logger import log_tool_action_warning from ..errors import ( ExecNonZeroError, @@ -1093,6 +1094,7 @@ def __init__( self._instrumentation = instrumentation or Instrumentation() self._dependencies = dependencies + @_data_redacted_async_boundary async def create( self, *, @@ -1157,6 +1159,7 @@ async def delete(self, session: SandboxSession) -> SandboxSession: pass return session + @_data_redacted_async_boundary async def resume( self, state: SandboxSessionState, @@ -1168,6 +1171,7 @@ async def resume( inner = UnixLocalSandboxSession.from_state(state) return self._wrap_session(inner, instrumentation=self._instrumentation) + @_data_redacted_boundary def deserialize_session_state(self, payload: dict[str, object]) -> SandboxSessionState: return self._deserialize_session_state_payload(payload, UnixLocalSandboxSessionState) diff --git a/src/agents/sandbox/session/base_sandbox_session.py b/src/agents/sandbox/session/base_sandbox_session.py index e497c610b9..e6c66a8b62 100644 --- a/src/agents/sandbox/session/base_sandbox_session.py +++ b/src/agents/sandbox/session/base_sandbox_session.py @@ -8,6 +8,7 @@ from typing_extensions import Self from ...editor import ApplyPatchOperation +from ...exceptions import _data_redacted_async_boundary from ...run_config import ( DEFAULT_MAX_LOCAL_DIR_FILE_CONCURRENCY, DEFAULT_MAX_MANIFEST_ENTRY_CONCURRENCY, @@ -196,6 +197,7 @@ class BaseSandboxSession(abc.ABC): state: SandboxSessionState + _mount_credential_cleanup_failed: bool = False _dependencies: Dependencies | None = None _dependencies_closed: bool = False _runtime_persist_workspace_skip_relpaths: set[Path] | None = None @@ -219,6 +221,7 @@ class BaseSandboxSession(abc.ABC): _max_local_dir_file_concurrency: int | None = DEFAULT_MAX_LOCAL_DIR_FILE_CONCURRENCY _archive_limits: SandboxArchiveLimits | None = None + @_data_redacted_async_boundary async def start(self) -> None: try: await self._ensure_backend_started() @@ -591,6 +594,7 @@ async def exec( :raises TimeoutError: If the sandbox cannot complete within `timeout`. """ + self._assert_mount_credential_cleanup_safe() sanitized_command = self._prepare_exec_command(*command, shell=shell, user=user) return await self._exec_internal(*sanitized_command, timeout=timeout) @@ -760,8 +764,16 @@ def _workspace_root_path(self) -> Path: return posix_path_as_path(self._workspace_path_policy().sandbox_root()) async def _validate_path_access(self, path: Path | str, *, for_write: bool = False) -> Path: + self._assert_mount_credential_cleanup_safe() return self.normalize_path(path, for_write=for_write) + def _assert_mount_credential_cleanup_safe(self) -> None: + if self._mount_credential_cleanup_failed: + raise MountConfigError( + message="sandbox mount credentials could not be revoked safely", + context={"session_invalidated": True}, + ) + async def _validate_remote_path_access( self, path: Path | str, @@ -1146,6 +1158,7 @@ async def apply_patch( return await WorkspaceEditor(self).apply_patch(operations, patch_format=patch_format) def normalize_path(self, path: Path | str, *, for_write: bool = False) -> Path: + self._assert_mount_credential_cleanup_safe() policy = self._workspace_path_policy() return policy.normalize_path(path, for_write=for_write) @@ -1203,6 +1216,7 @@ async def _apply_manifest( async def _validate_manifest_application(self, *, only_ephemeral: bool = False) -> None: _ = only_ephemeral + @_data_redacted_async_boundary async def apply_manifest(self, *, only_ephemeral: bool = False) -> MaterializationResult: await self._validate_manifest_application(only_ephemeral=only_ephemeral) return await self._apply_manifest( diff --git a/src/agents/sandbox/session/sandbox_client.py b/src/agents/sandbox/session/sandbox_client.py index 07fd581d68..ad10976a3a 100644 --- a/src/agents/sandbox/session/sandbox_client.py +++ b/src/agents/sandbox/session/sandbox_client.py @@ -5,10 +5,11 @@ from pydantic import BaseModel, ConfigDict, model_serializer +from ...exceptions import _data_redacted_boundary from .._mount_security import ( - REDACTED_MOUNT_CREDENTIAL_PATHS_KEY, - redact_manifest_mount_credentials, + REDACTED_HOST_PATH_GRANT_PATHS_KEY, validate_mount_credential_boundaries, + validate_mount_credential_environment_names, ) from ..manifest import Manifest from ..snapshot import SnapshotBase, SnapshotSpec @@ -16,10 +17,7 @@ from .dependencies import Dependencies from .manager import Instrumentation from .sandbox_session import SandboxSession -from .sandbox_session_state import ( - REDACTED_HOST_PATH_GRANT_PATHS_KEY, - SandboxSessionState, -) +from .sandbox_session_state import SandboxSessionState SandboxClientOptionsClass = type["BaseSandboxClientOptions"] ClientOptionsT = TypeVar("ClientOptionsT") @@ -111,9 +109,13 @@ class BaseSandboxClient(abc.ABC, Generic[ClientOptionsT]): _dependencies: Dependencies | None = None @staticmethod - def _validate_manifest_mount_credentials(manifest: Manifest | None) -> None: + def _validate_manifest_mount_credentials( + manifest: Manifest | None, + environment_names: object = None, + ) -> None: if manifest is not None: validate_mount_credential_boundaries(manifest) + validate_mount_credential_environment_names(manifest, environment_names) def _resolve_dependencies(self) -> Dependencies | None: if self._dependencies is None: @@ -183,11 +185,14 @@ async def resume( `session=` when you want to reuse an already-running sandbox session. """ + @_data_redacted_boundary def serialize_session_state(self, state: SandboxSessionState) -> dict[str, object]: """Serialize backend-specific sandbox state into a JSON-compatible payload.""" - validate_mount_credential_boundaries(state.manifest) + self._validate_manifest_mount_credentials( + state.manifest, + state._mount_credential_environment_names(), + ) redacted_paths = set(state.path_grants_require_rebind) - redacted_mount_paths = set(state.mount_credentials_require_rebind) persistent_grants = [] for grant in state.manifest.extra_path_grants: if grant.host_path is not None: @@ -198,17 +203,14 @@ def serialize_session_state(self, state: SandboxSessionState) -> dict[str, objec persistent_manifest = state.manifest.model_copy( update={"extra_path_grants": tuple(persistent_grants)}, ) - persistent_manifest, mount_paths = redact_manifest_mount_credentials(persistent_manifest) - redacted_mount_paths.update(mount_paths) persistent_state = state.model_copy(update={"manifest": persistent_manifest}) payload = cast(dict[str, object], persistent_state.model_dump(mode="json")) if redacted_paths: payload[REDACTED_HOST_PATH_GRANT_PATHS_KEY] = sorted(redacted_paths) - if redacted_mount_paths: - payload[REDACTED_MOUNT_CREDENTIAL_PATHS_KEY] = sorted(redacted_mount_paths) return payload @staticmethod + @_data_redacted_boundary def _deserialize_session_state_payload( payload: dict[str, object], state_class: type[SandboxSessionState], diff --git a/src/agents/sandbox/session/sandbox_session.py b/src/agents/sandbox/session/sandbox_session.py index 6aba057642..7c26f89e8b 100644 --- a/src/agents/sandbox/session/sandbox_session.py +++ b/src/agents/sandbox/session/sandbox_session.py @@ -10,6 +10,7 @@ from pathlib import Path from typing import Any, TypeVar, cast +from ...exceptions import _data_redacted_async_boundary from ...run_config import SandboxArchiveLimits, SandboxConcurrencyLimits from ...tracing import Span, custom_span, get_current_trace from ..errors import OpName, SandboxError @@ -513,6 +514,7 @@ async def _emit_finish_event( await self._instrumentation.emit(event) + @_data_redacted_async_boundary @instrumented_op("start") async def start(self) -> None: await self._inner.start() @@ -528,6 +530,7 @@ async def shutdown(self) -> None: async def _validate_manifest_application(self, *, only_ephemeral: bool = False) -> None: await self._inner._validate_manifest_application(only_ephemeral=only_ephemeral) + @_data_redacted_async_boundary async def apply_manifest(self, *, only_ephemeral: bool = False) -> MaterializationResult: return await super().apply_manifest(only_ephemeral=only_ephemeral) diff --git a/src/agents/sandbox/session/sandbox_session_state.py b/src/agents/sandbox/session/sandbox_session_state.py index 005ddc0efe..fc624f29e1 100644 --- a/src/agents/sandbox/session/sandbox_session_state.py +++ b/src/agents/sandbox/session/sandbox_session_state.py @@ -14,7 +14,9 @@ model_serializer, ) +from ...exceptions import _data_redacted_boundary from .._mount_security import ( + REDACTED_HOST_PATH_GRANT_PATHS_KEY, REDACTED_MOUNT_CREDENTIAL_PATHS_KEY, rebind_manifest_mount_credentials, redact_manifest_mount_credentials, @@ -25,7 +27,6 @@ from ..snapshot import SnapshotBase SessionStateClass = type["SandboxSessionState"] -REDACTED_HOST_PATH_GRANT_PATHS_KEY = "__openai_agents_redacted_host_path_grant_paths" class SandboxSessionState(BaseModel): @@ -41,7 +42,9 @@ class SandboxSessionState(BaseModel): _subclass_registry: ClassVar[dict[str, SessionStateClass]] = {} _path_grants_require_rebind: tuple[str, ...] = PrivateAttr(default=()) - _mount_credentials_require_rebind: tuple[str, ...] = PrivateAttr(default=()) + _mount_credentials_require_rebind: dict[str, tuple[str, ...]] = PrivateAttr( + default_factory=dict + ) @property def path_grants_require_rebind(self) -> tuple[str, ...]: @@ -49,7 +52,15 @@ def path_grants_require_rebind(self) -> tuple[str, ...]: @property def mount_credentials_require_rebind(self) -> tuple[str, ...]: - return self._mount_credentials_require_rebind + return tuple(self._mount_credentials_require_rebind) + + def _mount_credential_environment_names(self) -> tuple[str, ...]: + names: set[str] = set() + for field_name in ("base_envs", "base_env_vars", "secret_refs"): + value = getattr(self, field_name, None) + if isinstance(value, dict): + names.update(name for name in value if isinstance(name, str)) + return tuple(sorted(names)) @classmethod def __pydantic_init_subclass__(cls, **kwargs: Any) -> None: @@ -75,6 +86,7 @@ def __pydantic_init_subclass__(cls, **kwargs: Any) -> None: SandboxSessionState._subclass_registry[type_default] = cls @classmethod + @_data_redacted_boundary def parse(cls, payload: object) -> SandboxSessionState: """Deserialize *payload* into the correct registered subclass. @@ -133,14 +145,20 @@ def _mark_persisted_path_grants( sanitized_manifest = state.manifest.model_copy( update={"extra_path_grants": persistent_grants}, ) - sanitized_manifest, serialized_mount_credential_paths = redact_manifest_mount_credentials( + sanitized_manifest, serialized_mount_credentials = redact_manifest_mount_credentials( sanitized_manifest ) redacted_mount_value = payload.get(REDACTED_MOUNT_CREDENTIAL_PATHS_KEY) - mount_marker_paths = ( - tuple(path for path in redacted_mount_value if isinstance(path, str)) - if isinstance(redacted_mount_value, list | tuple) - else () + mount_marker_credentials = ( + { + path: tuple(field_names) + for path, field_names in redacted_mount_value.items() + if isinstance(path, str) + and isinstance(field_names, list) + and all(isinstance(field_name, str) for field_name in field_names) + } + if isinstance(redacted_mount_value, dict) + else {} ) marked = state.model_copy(update={"manifest": sanitized_manifest}) marked._path_grants_require_rebind = tuple( @@ -152,15 +170,19 @@ def _mark_persisted_path_grants( ) ) ) - marked._mount_credentials_require_rebind = tuple( - dict.fromkeys( - ( - *state.mount_credentials_require_rebind, - *mount_marker_paths, - *serialized_mount_credential_paths, + marked._mount_credentials_require_rebind = dict(state._mount_credentials_require_rebind) + for path, field_names in mount_marker_credentials.items(): + marked._mount_credentials_require_rebind[path] = tuple( + dict.fromkeys( + (*marked._mount_credentials_require_rebind.get(path, ()), *field_names) + ) + ) + for path, field_names in serialized_mount_credentials.items(): + marked._mount_credentials_require_rebind[path] = tuple( + dict.fromkeys( + (*marked._mount_credentials_require_rebind.get(path, ()), *field_names) ) ) - ) return marked def rebind_persisted_path_grants( @@ -223,10 +245,11 @@ def rebind_persisted_mount_credentials( rebound_manifest = rebind_manifest_mount_credentials( self.manifest, trusted_manifest, - self.mount_credentials_require_rebind, + self._mount_credentials_require_rebind, + allow_runtime_root_mismatch=(getattr(self, "workspace_root_owned", False) is True), ) rebound = self.model_copy(update={"manifest": rebound_manifest}) - rebound._mount_credentials_require_rebind = () + rebound._mount_credentials_require_rebind = {} return rebound def assert_trusted_manifest_rebound(self) -> None: @@ -247,7 +270,12 @@ def _serialize_always_include_defaults(self, handler: Any) -> dict[str, Any]: data["type"] = self.type if self.session_id: data["session_id"] = self.session_id - return data + if self.mount_credentials_require_rebind: + data[REDACTED_MOUNT_CREDENTIAL_PATHS_KEY] = { + path: list(field_names) + for path, field_names in self._mount_credentials_require_rebind.items() + } + return sanitize_serialized_session_state_mount_credentials(data) @field_validator("snapshot", mode="before") @classmethod diff --git a/tests/extensions/sandbox/test_blaxel.py b/tests/extensions/sandbox/test_blaxel.py index e3f7f3e469..753320456a 100644 --- a/tests/extensions/sandbox/test_blaxel.py +++ b/tests/extensions/sandbox/test_blaxel.py @@ -24,6 +24,7 @@ ExecTransportError, ExposedPortUnavailableError, InvalidManifestPathError, + MountConfigError, WorkspaceArchiveReadError, WorkspaceArchiveWriteError, WorkspaceReadNotFoundError, @@ -486,6 +487,47 @@ async def test_shutdown_pause_on_exit(self, fake_sandbox: _FakeSandboxInstance) await session.shutdown() assert fake_sandbox._deleted is False + @pytest.mark.asyncio + async def test_mount_credential_failure_force_deletes_paused_sandbox_and_persists_marker( + self, fake_sandbox: _FakeSandboxInstance + ) -> None: + state = _make_state(pause_on_exit=True) + session = _make_session(fake_sandbox, state=state) + + assert await session._force_terminate_after_mount_credential_failure() is True + + assert fake_sandbox._deleted is True + assert state.mount_credential_cleanup_failed is True + resumed = _make_session(_FakeSandboxInstance(name="resumed"), state=state) + assert await resumed.running() is False + with pytest.raises(MountConfigError, match="unavailable"): + await resumed.resolve_exposed_port(3000) + + @pytest.mark.asyncio + async def test_mount_credential_terminal_state_retries_delete_during_paused_shutdown( + self, fake_sandbox: _FakeSandboxInstance + ) -> None: + state = _make_state(pause_on_exit=True) + session = _make_session(fake_sandbox, state=state) + delete_calls = 0 + + async def _delete_once_fails() -> None: + nonlocal delete_calls + delete_calls += 1 + if delete_calls == 1: + raise ConnectionError("temporary delete failure") + fake_sandbox._deleted = True + + fake_sandbox.delete = _delete_once_fails # type: ignore[method-assign] + + assert await session._force_terminate_after_mount_credential_failure() is False + assert state.mount_credential_cleanup_failed is True + + await session.shutdown() + + assert delete_calls == 2 + assert fake_sandbox._deleted is True + @pytest.mark.asyncio async def test_normalize_path_relative(self, fake_sandbox: _FakeSandboxInstance) -> None: session = _make_session(fake_sandbox) @@ -773,6 +815,43 @@ async def test_create(self, monkeypatch: pytest.MonkeyPatch) -> None: session = await client.create(options=options) assert session is not None + @pytest.mark.asyncio + async def test_create_rejects_credential_environment_before_provider_call( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + from agents.extensions.sandbox.blaxel import sandbox as mod + from agents.extensions.sandbox.blaxel.mounts import BlaxelCloudBucketMountStrategy + from agents.sandbox.entries import S3Mount + + monkeypatch.setattr(mod, "_import_blaxel_sdk", lambda: _FakeSandboxInstance) + client = mod.BlaxelSandboxClient(token="test-token") + provider_imported = False + + def import_provider() -> object: + nonlocal provider_imported + provider_imported = True + raise AssertionError("provider import must not run") + + monkeypatch.setattr(mod, "_import_blaxel_sdk", import_provider) + manifest = Manifest( + entries={ + "remote": S3Mount( + bucket="example", + mount_strategy=BlaxelCloudBucketMountStrategy(), + ) + } + ) + + with pytest.raises(MountConfigError, match="credential-like environment"): + await client.create( + manifest=manifest, + options=mod.BlaxelSandboxClientOptions( + env_vars={"AWS_SECRET_ACCESS_KEY": "BLAXEL_ENV_SECRET_SENTINEL"} + ), + ) + + assert provider_imported is False + @pytest.mark.asyncio async def test_create_with_dictionary_run_config_options( self, monkeypatch: pytest.MonkeyPatch @@ -835,6 +914,9 @@ async def test_resume_reconnects(self, monkeypatch: pytest.MonkeyPatch) -> None: state = _make_state(sandbox_name="resume-sandbox", pause_on_exit=True) session = await client.resume(state) assert session is not None + assert Path(".sandbox-blaxel-mount-credentials") in ( + session._inner._persist_workspace_skip_relpaths() # noqa: SLF001 + ) @pytest.mark.asyncio async def test_resume_creates_new(self, monkeypatch: pytest.MonkeyPatch) -> None: @@ -847,6 +929,40 @@ async def test_resume_creates_new(self, monkeypatch: pytest.MonkeyPatch) -> None session = await client.resume(state) assert session is not None + @pytest.mark.asyncio + async def test_resume_rejects_credential_environment_before_provider_call( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + from agents.extensions.sandbox.blaxel import sandbox as mod + from agents.extensions.sandbox.blaxel.mounts import BlaxelCloudBucketMountStrategy + from agents.sandbox.entries import S3Mount + + monkeypatch.setattr(mod, "_import_blaxel_sdk", lambda: _FakeSandboxInstance) + client = mod.BlaxelSandboxClient(token="test-token") + state = _make_state(sandbox_name="credential-environment") + state.manifest = Manifest( + entries={ + "remote": S3Mount( + bucket="example", + mount_strategy=BlaxelCloudBucketMountStrategy(), + ) + } + ) + state.base_env_vars = {"AWS_SECRET_ACCESS_KEY": "BLAXEL_RESUME_ENV_SECRET_SENTINEL"} + provider_imported = False + + def import_provider() -> object: + nonlocal provider_imported + provider_imported = True + raise AssertionError("provider import must not run") + + monkeypatch.setattr(mod, "_import_blaxel_sdk", import_provider) + + with pytest.raises(MountConfigError, match="credential-like environment"): + await client.resume(state) + + assert provider_imported is False + @pytest.mark.asyncio async def test_deserialize_session_state(self, monkeypatch: pytest.MonkeyPatch) -> None: from agents.extensions.sandbox.blaxel import sandbox as mod @@ -2524,6 +2640,30 @@ def __init__(self, name: str = "no-url") -> None: session = await client.resume(state) assert session is not None + @pytest.mark.asyncio + async def test_resume_rejects_terminal_mount_credential_state_before_provider_access( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + from agents.extensions.sandbox.blaxel import sandbox as mod + + monkeypatch.setattr(mod, "_import_blaxel_sdk", lambda: _FakeSandboxInstance) + client = mod.BlaxelSandboxClient(token="test-token") + state = _make_state(sandbox_name="terminal", pause_on_exit=True) + state.mount_credential_cleanup_failed = True + provider_accesses = 0 + + def _unexpected_provider_access() -> type[_FakeSandboxInstance]: + nonlocal provider_accesses + provider_accesses += 1 + return _FakeSandboxInstance + + monkeypatch.setattr(mod, "_import_blaxel_sdk", _unexpected_provider_access) + + with pytest.raises(MountConfigError, match="unavailable"): + await client.resume(state) + + assert provider_accesses == 0 + @pytest.mark.asyncio async def test_delete_shutdown_raises(self, monkeypatch: pytest.MonkeyPatch) -> None: from agents.extensions.sandbox.blaxel import sandbox as mod diff --git a/tests/extensions/sandbox/test_blaxel_mounts.py b/tests/extensions/sandbox/test_blaxel_mounts.py index 1c84ef0fac..62194fe874 100644 --- a/tests/extensions/sandbox/test_blaxel_mounts.py +++ b/tests/extensions/sandbox/test_blaxel_mounts.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import io import shlex import uuid @@ -16,7 +17,8 @@ _mount_gcs, _mount_s3, ) -from agents.sandbox.errors import MountConfigError +from agents.sandbox.entries import S3Mount +from agents.sandbox.errors import MountCommandError, MountConfigError _INJECTION = "x; touch /tmp/pwned" @@ -32,22 +34,41 @@ def test_sensitive_value_redaction_handles_overlapping_values() -> None: assert _redact_sensitive_values("abcdef", ["abc", "abcdef"]) == "REDACTED" +def test_sensitive_value_redaction_handles_shell_quoted_values() -> None: + from agents.extensions.sandbox.blaxel.mounts import _redact_sensitive_values + + secret = "ab'cd" + + assert _redact_sensitive_values(shlex.quote(secret), [secret]) == "REDACTED" + + class _RecordingSession: """Minimal sandbox session that records the `sh -c` commands it is asked to run.""" def __init__(self) -> None: self.commands: list[str] = [] - self.state = SimpleNamespace(session_id=uuid.uuid4()) + self.state = SimpleNamespace( + session_id=uuid.uuid4(), + manifest=SimpleNamespace(root="/workspace"), + ) self.writes: dict[str, bytes] = {} self.persist_skip_paths: list[Path] = [] self.mount_failure: bytes | None = None self.checked_failure: str | None = None + self.cancel_checked_once: str | None = None + self.write_failure = False + self.empty_write_failure = False + self.unmount_failure = False + self.shutdown_calls = 0 + self._mount_credential_cleanup_failed = False async def exec(self, *args: Any, **kwargs: Any) -> Any: if len(args) >= 3 and args[0] == "sh" and args[1] == "-c": self.commands.append(args[2]) if self.mount_failure is not None and args[2].startswith(("s3fs", "gcsfuse")): return SimpleNamespace(exit_code=1, stdout=b"", stderr=self.mount_failure) + if self.unmount_failure and args[2].startswith(("fusermount", "umount")): + return SimpleNamespace(exit_code=1, stdout=b"", stderr=b"unmount failed") return SimpleNamespace(exit_code=0, stdout=b"", stderr=b"") def register_persist_workspace_skip_path(self, path: Path) -> None: @@ -57,17 +78,33 @@ async def mkdir(self, path: Path, *, parents: bool = False) -> None: _ = (path, parents) async def write(self, path: Path, data: io.IOBase) -> None: - self.writes[path.as_posix()] = data.read() + payload = data.read() + self.writes[path.as_posix()] = payload + if self.write_failure: + raise RuntimeError(f"transport echoed payload: {payload!r}") + if self.empty_write_failure and payload == b"": + raise RuntimeError("empty overwrite failed") def normalize_path(self, path: Path) -> Path: return Path("/workspace") / path async def _exec_checked_nonzero(self, *args: str) -> Any: self.commands.append(" ".join(args)) + if self.cancel_checked_once == args[0]: + self.cancel_checked_once = None + raise asyncio.CancelledError() if self.checked_failure == args[0]: raise RuntimeError(f"{args[0]} failed") return SimpleNamespace(exit_code=0, stdout=b"", stderr=b"") + async def shutdown(self) -> None: + self.shutdown_calls += 1 + + async def _force_terminate_after_mount_credential_failure(self) -> bool: + self._mount_credential_cleanup_failed = True + self.shutdown_calls += 1 + return True + async def test_s3_mount_options_are_shell_quoted() -> None: session = _RecordingSession() @@ -126,6 +163,38 @@ async def test_mount_failure_does_not_attach_credentials_to_commands_or_errors( assert secret not in repr(exc_info.value.context) assert exc_info.value.context["stderr"] == "provider error included REDACTED" assert session.persist_skip_paths == [Path(".sandbox-blaxel-mount-credentials")] + assert any(command.startswith("fusermount -u ") for command in session.commands) + traceback = exc_info.value.__traceback__ + while traceback is not None: + if "/src/agents/" in Path(traceback.tb_frame.f_code.co_filename).as_posix(): + assert secret not in repr(traceback.tb_frame.f_locals) + traceback = traceback.tb_next + + +async def test_blaxel_mount_activation_clears_sensitive_frames_at_mount_boundary( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from agents.extensions.sandbox.blaxel import mounts as mounts_module + + session = _RecordingSession() + secret = "blaxel-public-boundary-secret-sentinel" + session.mount_failure = f"provider error included {secret}".encode() + mount = S3Mount( + bucket="bucket", + access_key_id="access", + secret_access_key=secret, + mount_strategy=BlaxelCloudBucketMountStrategy(), + ) + monkeypatch.setattr(mounts_module, "_assert_blaxel_session", lambda session: None) + + with pytest.raises(MountConfigError) as exc_info: + await mount.apply(session, Path("/workspace/remote"), Path.cwd()) # type: ignore[arg-type] + + traceback = exc_info.value.__traceback__ + while traceback is not None: + if "/src/agents/" in Path(traceback.tb_frame.f_code.co_filename).as_posix(): + assert secret not in repr(traceback.tb_frame.f_locals) + traceback = traceback.tb_next async def test_credential_writer_removes_partial_file_when_chmod_fails() -> None: @@ -135,7 +204,7 @@ async def test_credential_writer_removes_partial_file_when_chmod_fails() -> None session.checked_failure = "chmod" secret = "credential-sentinel" - with pytest.raises(RuntimeError, match="chmod failed"): + with pytest.raises(MountCommandError): await _write_mount_credential_file( session, # type: ignore[arg-type] name="credential-file", @@ -146,11 +215,74 @@ async def test_credential_writer_removes_partial_file_when_chmod_fails() -> None assert secret not in " ".join(session.commands) -async def test_mount_fails_when_credential_file_cleanup_is_unconfirmed() -> None: +@pytest.mark.parametrize("cleanup_fails", [False, True]) +async def test_credential_writer_drops_payload_from_write_failure_chain( + cleanup_fails: bool, +) -> None: + from agents.extensions.sandbox.blaxel.mounts import _write_mount_credential_file + + session = _RecordingSession() + session.write_failure = True + session.checked_failure = "rm" if cleanup_fails else None + secret = "BLAXEL_WRITE_FAILURE_SECRET_SENTINEL" + + with pytest.raises(MountCommandError) as exc_info: + await _write_mount_credential_file( + session, # type: ignore[arg-type] + name="credential-file", + content=secret, + ) + + error = exc_info.value + assert secret not in repr(error) + assert error.__cause__ is None + assert error.__context__ is None + assert error.context["cleanup_confirmed"] is (not cleanup_fails) + + +@pytest.mark.parametrize("provider", ["s3", "gcs"]) +async def test_mount_detaches_and_revokes_credentials_when_file_removal_fails( + provider: str, +) -> None: session = _RecordingSession() session.checked_failure = "rm" + secret = f"{provider}-cleanup-secret" + config = BlaxelCloudBucketMountConfig( + provider=provider, # type: ignore[arg-type] + bucket="bucket", + mount_path="/mnt/data", + access_key_id="access" if provider == "s3" else None, + secret_access_key=secret if provider == "s3" else None, + service_account_key=secret if provider == "gcs" else None, + ) - with pytest.raises(RuntimeError, match="rm failed"): + with pytest.raises(MountConfigError, match="cleanup could not complete normally") as exc_info: + if provider == "s3": + await _mount_s3(session, config) # type: ignore[arg-type] + else: + await _mount_gcs(session, config) # type: ignore[arg-type] + + assert any(command.startswith("fusermount -u ") for command in session.commands) + assert b"" in session.writes.values() + assert secret not in repr(exc_info.value) + assert exc_info.value.__cause__ is None + assert exc_info.value.__context__ is None + assert exc_info.value.context == { + "mount_detached": True, + "credential_revoked": True, + "session_invalidated": False, + "termination_confirmed": False, + } + assert session.shutdown_calls == 0 + + +async def test_mount_invalidates_session_when_credential_revocation_is_unconfirmed() -> None: + session = _RecordingSession() + session.checked_failure = "rm" + session.empty_write_failure = True + session.unmount_failure = True + + with pytest.raises(MountConfigError) as exc_info: await _mount_s3( session, # type: ignore[arg-type] BlaxelCloudBucketMountConfig( @@ -161,3 +293,42 @@ async def test_mount_fails_when_credential_file_cleanup_is_unconfirmed() -> None secret_access_key="secret", ), ) + + assert exc_info.value.context == { + "mount_detached": False, + "credential_revoked": False, + "session_invalidated": True, + "termination_confirmed": True, + } + assert session._mount_credential_cleanup_failed is True + assert session.shutdown_calls == 1 + + +@pytest.mark.parametrize("provider", ["s3", "gcs"]) +async def test_mount_cleanup_completes_before_propagating_cancellation(provider: str) -> None: + session = _RecordingSession() + session.cancel_checked_once = "rm" + secret = f"{provider}-cancelled-cleanup-secret" + config = BlaxelCloudBucketMountConfig( + provider=provider, # type: ignore[arg-type] + bucket="bucket", + mount_path="/mnt/data", + access_key_id="access" if provider == "s3" else None, + secret_access_key=secret if provider == "s3" else None, + service_account_key=secret if provider == "gcs" else None, + ) + + with pytest.raises(asyncio.CancelledError) as exc_info: + if provider == "s3": + await _mount_s3(session, config) # type: ignore[arg-type] + else: + await _mount_gcs(session, config) # type: ignore[arg-type] + + assert any(command.startswith("fusermount -u ") for command in session.commands) + assert b"" in session.writes.values() + assert session._mount_credential_cleanup_failed is False + traceback = exc_info.value.__traceback__ + while traceback is not None: + if "/src/agents/" in Path(traceback.tb_frame.f_code.co_filename).as_posix(): + assert secret not in repr(traceback.tb_frame.f_locals) + traceback = traceback.tb_next diff --git a/tests/extensions/sandbox/test_cloudflare.py b/tests/extensions/sandbox/test_cloudflare.py index a045ddae35..e399980b28 100644 --- a/tests/extensions/sandbox/test_cloudflare.py +++ b/tests/extensions/sandbox/test_cloudflare.py @@ -871,6 +871,95 @@ async def test_cloudflare_mount_and_unmount_bucket_use_http_endpoints() -> None: assert unmount_call["json"] == {"mountPath": "/workspace/data"} +@pytest.mark.asyncio +async def test_cloudflare_mount_error_does_not_reflect_submitted_credentials() -> None: + sentinel = "CLOUDFLARE_MOUNT_CREDENTIAL_SENTINEL" + fake_http = _FakeHttp({"POST /mount": _FakeResponse(status=500, json_body={"error": sentinel})}) + sess = _make_session(fake_http=fake_http) + + with pytest.raises(MountConfigError) as exc_info: + await sess.mount_bucket( + bucket="my-bucket", + mount_path=Path("/workspace/data"), + options={"credentials": {"secretAccessKey": sentinel}}, + ) + + assert exc_info.value.context == { + "bucket": "my-bucket", + "mount_path": "/workspace/data", + "http_status": 500, + } + assert sentinel not in repr(exc_info.value) + traceback = exc_info.value.__traceback__ + while traceback is not None: + if "/src/agents/" in Path(traceback.tb_frame.f_code.co_filename).as_posix(): + assert sentinel not in repr(traceback.tb_frame.f_locals) + traceback = traceback.tb_next + + +@pytest.mark.asyncio +async def test_cloudflare_mount_transport_error_drops_sensitive_exception() -> None: + sentinel = "CLOUDFLARE_MOUNT_TRANSPORT_SENTINEL" + + class _FailingHttp(_FakeHttp): + def post(self, url: str, **kwargs: Any) -> Any: + self._record("POST", url, **kwargs) + raise aiohttp.ClientError(sentinel) + + sess = _make_session(fake_http=_FailingHttp()) + + with pytest.raises(MountConfigError) as exc_info: + await sess.mount_bucket( + bucket="my-bucket", + mount_path=Path("/workspace/data"), + options={"credentials": {"secretAccessKey": sentinel}}, + ) + + assert exc_info.value.context == { + "bucket": "my-bucket", + "mount_path": "/workspace/data", + "cause_type": "ClientError", + } + assert sentinel not in repr(exc_info.value) + assert exc_info.value.__cause__ is None + assert exc_info.value.__context__ is None + traceback = exc_info.value.__traceback__ + while traceback is not None: + if "/src/agents/" in Path(traceback.tb_frame.f_code.co_filename).as_posix(): + assert sentinel not in repr(traceback.tb_frame.f_locals) + traceback = traceback.tb_next + + +@pytest.mark.parametrize("error_type", [asyncio.TimeoutError, RuntimeError]) +@pytest.mark.asyncio +async def test_cloudflare_mount_normalizes_all_ordinary_transport_errors( + error_type: type[Exception], +) -> None: + sentinel = "CLOUDFLARE_MOUNT_GENERIC_TRANSPORT_SENTINEL" + + class _FailingHttp(_FakeHttp): + def post(self, url: str, **kwargs: Any) -> Any: + self._record("POST", url, **kwargs) + raise error_type(sentinel) + + with pytest.raises(MountConfigError) as exc_info: + await _make_session(fake_http=_FailingHttp()).mount_bucket( + bucket="my-bucket", + mount_path=Path("/workspace/data"), + options={"credentials": {"secretAccessKey": sentinel}}, + ) + + assert exc_info.value.context["cause_type"] == error_type.__name__ + assert sentinel not in repr(exc_info.value) + assert exc_info.value.__cause__ is None + assert exc_info.value.__context__ is None + traceback = exc_info.value.__traceback__ + while traceback is not None: + if "/src/agents/" in Path(traceback.tb_frame.f_code.co_filename).as_posix(): + assert sentinel not in repr(traceback.tb_frame.f_locals) + traceback = traceback.tb_next + + @pytest.mark.asyncio async def test_cloudflare_mount_and_unmount_validate_path_access_for_write() -> None: fake_http = _FakeHttp( diff --git a/tests/extensions/sandbox/test_daytona.py b/tests/extensions/sandbox/test_daytona.py index a5420f6288..58bf444c46 100644 --- a/tests/extensions/sandbox/test_daytona.py +++ b/tests/extensions/sandbox/test_daytona.py @@ -614,6 +614,34 @@ async def test_create_passes_only_option_env_vars_to_daytona( "ONLY_OPTION": "1", } + @pytest.mark.asyncio + async def test_create_rejects_credential_environment_before_daytona_call( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + daytona_module = _load_daytona_module(monkeypatch) + create_call_count = len(_FakeAsyncDaytona.create_calls) + manifest = Manifest( + root=daytona_module.DEFAULT_DAYTONA_WORKSPACE_ROOT, + entries={ + "remote": S3Mount( + bucket="example", + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ) + }, + ) + + async with daytona_module.DaytonaSandboxClient() as client: + with pytest.raises(MountConfigError, match="credential-like environment"): + await client.create( + manifest=manifest, + options=daytona_module.DaytonaSandboxClientOptions( + env_vars={"AWS_SECRET_ACCESS_KEY": "DAYTONA_ENV_SECRET_SENTINEL"} + ), + ) + + assert len(_FakeAsyncDaytona.create_calls) == create_call_count + @pytest.mark.asyncio async def test_exec_enforces_subsecond_caller_timeout( self, diff --git a/tests/extensions/sandbox/test_e2b.py b/tests/extensions/sandbox/test_e2b.py index 80c25d8d01..075b1e559c 100644 --- a/tests/extensions/sandbox/test_e2b.py +++ b/tests/extensions/sandbox/test_e2b.py @@ -34,6 +34,7 @@ from agents.sandbox import Manifest from agents.sandbox.entries import ( Dir, + FuseMountPattern, InContainerMountStrategy, Mount, MountpointMountPattern, @@ -41,6 +42,7 @@ S3Mount, ) from agents.sandbox.entries.mounts.base import InContainerMountAdapter +from agents.sandbox.entries.mounts.patterns import MountPatternBase from agents.sandbox.errors import ( ExecTimeoutError, ExecTransportError, @@ -560,7 +562,7 @@ def bind_events(self, events: list[tuple[str, str]]) -> _RecordingMount: def supported_in_container_patterns( self, - ) -> tuple[builtins.type[MountpointMountPattern], ...]: + ) -> tuple[builtins.type[MountPatternBase], ...]: return (MountpointMountPattern,) def build_docker_volume_driver_config( @@ -628,6 +630,18 @@ async def restore_after_snapshot( return _Adapter(self) +class _FuseRecordingMount(_RecordingMount): + type: str = "fuse_recording_mount" + mount_strategy: InContainerMountStrategy = Field( + default_factory=lambda: InContainerMountStrategy(pattern=FuseMountPattern()) + ) + + def supported_in_container_patterns( + self, + ) -> tuple[builtins.type[FuseMountPattern], ...]: + return (FuseMountPattern,) + + class _FailingUnmountMount(_RecordingMount): type: str = "failing_unmount_mount" @@ -848,6 +862,39 @@ def _is_helper_present_command(command: str) -> bool: ) +@pytest.mark.asyncio +async def test_e2b_create_rejects_credential_environment_before_provider_call( + monkeypatch: pytest.MonkeyPatch, +) -> None: + provider_imported = False + + def import_sandbox(_sandbox_type: object) -> object: + nonlocal provider_imported + provider_imported = True + raise AssertionError("provider import must not run") + + monkeypatch.setattr(e2b_module, "_import_sandbox_class", import_sandbox) + manifest = Manifest( + entries={ + "remote": S3Mount( + bucket="example", + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ) + } + ) + + with pytest.raises(MountConfigError, match="credential-like environment"): + await E2BSandboxClient().create( + manifest=manifest, + options=E2BSandboxClientOptions( + sandbox_type="e2b", + envs={"AWS_SECRET_ACCESS_KEY": "E2B_ENV_SECRET_SENTINEL"}, + ), + ) + + assert provider_imported is False + + @pytest.mark.asyncio async def test_e2b_exec_omits_cwd_until_workspace_ready() -> None: session, sandbox = _session(workspace_root_ready=False) @@ -1282,6 +1329,28 @@ def test_e2b_deserialize_session_state_defaults_missing_mcp() -> None: assert restored.mcp is None +def test_e2b_serialize_rejects_persisted_credential_environment() -> None: + state = E2BSandboxSessionState( + session_id=uuid.uuid4(), + manifest=Manifest( + entries={ + "remote": S3Mount( + bucket="example", + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ) + } + ), + snapshot=NoopSnapshot(id="snapshot"), + sandbox_id="sb-123", + base_envs={"AWS_SECRET_ACCESS_KEY": "E2B_PERSISTED_ENV_SECRET_SENTINEL"}, + ) + + with pytest.raises(MountConfigError, match="credential-like environment") as exc_info: + E2BSandboxClient().serialize_session_state(state) + + assert "E2B_PERSISTED_ENV_SECRET_SENTINEL" not in repr(exc_info.value) + + def test_e2b_client_options_preserves_positional_exposed_ports() -> None: options = E2BSandboxClientOptions( "e2b", @@ -1650,6 +1719,38 @@ async def create_snapshot(self) -> object: ] +@pytest.mark.asyncio +async def test_e2b_native_snapshot_falls_back_to_tar_for_blobfuse_cache() -> None: + events: list[tuple[str, str]] = [] + mount = _FuseRecordingMount().bind_events(events) + sandbox = _FakeE2BSandbox() + sandbox.commands.exec_root_ready = True + sandbox.commands.next_result = _FakeE2BResult( + stdout=base64.b64encode(b"fake-tar-bytes").decode("ascii") + ) + state = E2BSandboxSessionState( + session_id=uuid.uuid4(), + manifest=Manifest(root="/workspace", entries={"mount": mount}), + snapshot=NoopSnapshot(id="snapshot"), + sandbox_id=sandbox.sandbox_id, + workspace_root_ready=True, + workspace_persistence="snapshot", + ) + session = E2BSandboxSession.from_state(state, sandbox=sandbox) + + archive = await session.persist_workspace() + + assert archive.read() == b"fake-tar-bytes" + assert sandbox.commands.calls + command = sandbox.commands.calls[0]["command"] + assert isinstance(command, str) + assert ".sandbox-blobfuse-cache" in command + assert events == [ + ("unmount", "/workspace/mount"), + ("mount", "/workspace/mount"), + ] + + @pytest.mark.asyncio async def test_e2b_persist_workspace_native_snapshot_falls_back_to_tar_for_plain_skip_paths() -> ( None diff --git a/tests/extensions/sandbox/test_modal.py b/tests/extensions/sandbox/test_modal.py index 047b6ce61b..457fcb9b3e 100644 --- a/tests/extensions/sandbox/test_modal.py +++ b/tests/extensions/sandbox/test_modal.py @@ -25,6 +25,7 @@ Mount, MountpointMountPattern, R2Mount, + RcloneMountPattern, S3Mount, ) from agents.sandbox.entries.mounts.base import InContainerMountAdapter @@ -62,6 +63,36 @@ def _set_aio_attr(obj: object, name: str, fn: Callable[..., object]) -> None: setattr(obj, name, _with_aio(fn)) +@pytest.mark.parametrize("workspace_persistence", ["snapshot_filesystem", "snapshot_directory"]) +def test_modal_native_snapshot_reconstructs_mount_credential_skip_paths( + monkeypatch: pytest.MonkeyPatch, + workspace_persistence: str, +) -> None: + modal_module, _create_calls, _registry_tags = _load_modal_module(monkeypatch) + state = modal_module.ModalSandboxSessionState( + manifest=Manifest( + root="/workspace", + entries={ + "credentials.json": File(content=b"credentials"), + "remote": GCSMount( + bucket="example", + service_account_file="credentials.json", + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ), + }, + ), + snapshot=modal_module.resolve_snapshot(None, "snapshot"), + app_name="sandbox-tests", + workspace_persistence=workspace_persistence, + ) + session = modal_module.ModalSandboxSession.from_state(state, sandbox=object()) + + assert session._modal_snapshot_plain_skip_relpaths(Path("/workspace")) == { + Path(".sandbox-rclone-config"), + Path("credentials.json"), + } + + class _RecordingMount(Mount): type: str = "modal_recording_mount" mount_strategy: InContainerMountStrategy = Field( @@ -638,6 +669,59 @@ async def test_modal_sandbox_create_passes_modal_cloud_bucket_mounts( assert mount.read_only is False +@pytest.mark.parametrize("failure_stage", ["secret", "create", "cancel"]) +@pytest.mark.asyncio +async def test_modal_native_mount_failures_clear_credential_traceback_frames( + monkeypatch: pytest.MonkeyPatch, + failure_stage: str, +) -> None: + modal_module, _create_calls, _registry_tags = _load_modal_module(monkeypatch) + sentinel = "MODAL_SECRET_TRACE_SENTINEL" + + if failure_stage == "secret": + + def fail_secret(value: dict[str, str]) -> NoReturn: + raise RuntimeError(f"secret construction reflected {value!r}") + + monkeypatch.setattr(modal_module.modal.Secret, "from_dict", staticmethod(fail_secret)) + expected_error: type[BaseException] = MountConfigError + else: + + async def fail_create(**_kwargs: object) -> NoReturn: + if failure_stage == "cancel": + raise asyncio.CancelledError + raise RuntimeError(f"sandbox creation reflected {sentinel}") + + monkeypatch.setattr(modal_module.modal.Sandbox.create, "aio", fail_create) + expected_error = asyncio.CancelledError if failure_stage == "cancel" else MountConfigError + + client = modal_module.ModalSandboxClient() + with pytest.raises(expected_error) as exc_info: + await client.create( + manifest=Manifest( + entries={ + "remote": S3Mount( + bucket="bucket", + access_key_id="access-key", + secret_access_key=sentinel, + mount_strategy=modal_module.ModalCloudBucketMountStrategy(), + ) + } + ), + options=modal_module.ModalSandboxClientOptions(app_name="sandbox-tests"), + ) + + error = exc_info.value + assert sentinel not in repr(error) + assert error.__cause__ is None + assert error.__context__ is None + traceback = error.__traceback__ + while traceback is not None: + if "/src/agents/" in Path(traceback.tb_frame.f_code.co_filename).as_posix(): + assert sentinel not in repr(traceback.tb_frame.f_locals) + traceback = traceback.tb_next + + @pytest.mark.asyncio async def test_modal_sandbox_create_passes_named_modal_secret_for_cloud_bucket_mount( monkeypatch: pytest.MonkeyPatch, @@ -2087,7 +2171,13 @@ async def _fake_exec( assert "logs/events.jsonl" in commands[0][2] assert "actual" not in commands[0][2] assert "logical" not in commands[0][2] - assert commands[1] == ["rm", "-rf", "--", "/workspace/logs/events.jsonl"] + assert commands[1] == [ + "rm", + "-rf", + "--", + "/workspace/.sandbox-mountpoint-env", + "/workspace/logs/events.jsonl", + ] @pytest.mark.asyncio @@ -2177,10 +2267,16 @@ async def _fake_exec( assert "logical" not in commands[0][2] assert "/tmp/openai-agents/session-state/" in commands[0][2] assert "modal-snapshot-directory-ephemeral.tar" in commands[0][2] - assert "for rel in logs/events.jsonl;" in commands[0][2] + assert "for rel in .sandbox-mountpoint-env logs/events.jsonl;" in commands[0][2] assert "tar cf" in commands[0][2] assert "-T -" in commands[0][2] - assert commands[1] == ["rm", "-rf", "--", "/workspace/logs/events.jsonl"] + assert commands[1] == [ + "rm", + "-rf", + "--", + "/workspace/.sandbox-mountpoint-env", + "/workspace/logs/events.jsonl", + ] assert commands[2][0:2] == ["sh", "-lc"] assert "modal-snapshot-directory-ephemeral.tar" in commands[2][2] assert "tar xf" in commands[2][2] @@ -2321,8 +2417,14 @@ async def _fake_exec( assert str(exc_info.value.cause) == "teardown failed" assert events == [("unmount", "/workspace/actual-1"), ("mount", "/workspace/actual-1")] assert commands[0][0:2] == ["sh", "-lc"] - assert "for rel in tmp.txt;" in commands[0][2] - assert commands[1] == ["rm", "-rf", "--", "/workspace/tmp.txt"] + assert "for rel in .sandbox-mountpoint-env tmp.txt;" in commands[0][2] + assert commands[1] == [ + "rm", + "-rf", + "--", + "/workspace/.sandbox-mountpoint-env", + "/workspace/tmp.txt", + ] assert commands[2][0:2] == ["sh", "-lc"] assert "modal-snapshot-directory-ephemeral.tar" in commands[2][2] assert "tar xf" in commands[2][2] @@ -2474,10 +2576,16 @@ async def _fake_exec( if rendered == [ "sh", "-lc", - "cd -- /workspace && (tar cf - -- tmp.txt 2>/dev/null || true)", + "cd -- /workspace && (tar cf - -- .sandbox-mountpoint-env tmp.txt 2>/dev/null || true)", ]: return ExecResult(stdout=b"ephemeral-backup", stderr=b"", exit_code=0) - if rendered == ["rm", "-rf", "--", "/workspace/tmp.txt"]: + if rendered == [ + "rm", + "-rf", + "--", + "/workspace/.sandbox-mountpoint-env", + "/workspace/tmp.txt", + ]: return ExecResult(stdout=b"", stderr=b"", exit_code=0) raise AssertionError(f"unexpected command: {rendered!r}") @@ -2505,8 +2613,18 @@ async def _fake_call_modal( } assert sandbox.restore_payloads == [b"ephemeral-backup"] assert commands == [ - ["sh", "-lc", "cd -- /workspace && (tar cf - -- tmp.txt 2>/dev/null || true)"], - ["rm", "-rf", "--", "/workspace/tmp.txt"], + [ + "sh", + "-lc", + "cd -- /workspace && (tar cf - -- .sandbox-mountpoint-env tmp.txt 2>/dev/null || true)", + ], + [ + "rm", + "-rf", + "--", + "/workspace/.sandbox-mountpoint-env", + "/workspace/tmp.txt", + ], ] assert events == [("snapshot", "")] @@ -2636,6 +2754,8 @@ async def _fake_exec( "cf", "-", "--exclude", + "./.sandbox-mountpoint-env", + "--exclude", "./actual", "-C", "/workspace", diff --git a/tests/extensions/sandbox/test_runloop.py b/tests/extensions/sandbox/test_runloop.py index ec6999d252..65f7a6857d 100644 --- a/tests/extensions/sandbox/test_runloop.py +++ b/tests/extensions/sandbox/test_runloop.py @@ -21,8 +21,15 @@ from agents.sandbox import Manifest, SandboxPathGrant from agents.sandbox.capabilities import Shell from agents.sandbox.capabilities.tools.shell_tool import ExecCommandArgs, ExecCommandTool -from agents.sandbox.entries import File, InContainerMountStrategy, Mount, MountpointMountPattern +from agents.sandbox.entries import ( + File, + InContainerMountStrategy, + Mount, + RcloneMountPattern, + S3Mount, +) from agents.sandbox.entries.mounts.base import InContainerMountAdapter +from agents.sandbox.errors import MountConfigError from agents.sandbox.manifest import Environment from agents.sandbox.materialization import MaterializedFile from agents.sandbox.session.base_sandbox_session import BaseSandboxSession @@ -1298,15 +1305,15 @@ def test_runloop_package_re_exports_backend_symbols(monkeypatch: pytest.MonkeyPa class _RecordingMount(Mount): type: str = "runloop_recording_mount" mount_strategy: InContainerMountStrategy = Field( - default_factory=lambda: InContainerMountStrategy(pattern=MountpointMountPattern()) + default_factory=lambda: InContainerMountStrategy(pattern=RcloneMountPattern()) ) _mounted_paths: list[Path] = PrivateAttr(default_factory=list) _unmounted_paths: list[Path] = PrivateAttr(default_factory=list) def supported_in_container_patterns( self, - ) -> tuple[builtins.type[MountpointMountPattern], ...]: - return (MountpointMountPattern,) + ) -> tuple[builtins.type[RcloneMountPattern], ...]: + return (RcloneMountPattern,) def in_container_adapter(self) -> InContainerMountAdapter: mount = self @@ -1518,6 +1525,35 @@ async def test_create_merges_env_vars_with_manifest_precedence( "ONLY_OPTION": "1", } + @pytest.mark.asyncio + async def test_create_rejects_managed_credential_before_secret_upsert( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + runloop_module = _load_runloop_module(monkeypatch) + manifest = Manifest( + root=runloop_module.DEFAULT_RUNLOOP_WORKSPACE_ROOT, + entries={ + "remote": S3Mount( + bucket="example", + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ) + }, + ) + + async with runloop_module.RunloopSandboxClient() as client: + sdk = _FakeAsyncRunloopSDK.created_instances[-1] + with pytest.raises(MountConfigError, match="credential-like environment"): + await client.create( + manifest=manifest, + options=runloop_module.RunloopSandboxClientOptions( + managed_secrets={"AWS_SECRET_ACCESS_KEY": "RUNLOOP_MANAGED_SECRET_SENTINEL"} + ), + ) + + assert sdk.secret.create_calls == [] + assert sdk.devbox.create_calls == [] + def test_runloop_client_options_preserve_positional_exposed_ports( self, monkeypatch: pytest.MonkeyPatch, diff --git a/tests/extensions/sandbox/test_vercel.py b/tests/extensions/sandbox/test_vercel.py index 409f15db86..baacff1610 100644 --- a/tests/extensions/sandbox/test_vercel.py +++ b/tests/extensions/sandbox/test_vercel.py @@ -21,7 +21,7 @@ File, InContainerMountStrategy, Mount, - MountpointMountPattern, + RcloneMountPattern, S3Mount, ) from agents.sandbox.entries.mounts.base import InContainerMountAdapter @@ -400,15 +400,15 @@ class _RecordingMount(Mount): def supported_in_container_patterns( self, - ) -> tuple[builtins.type[MountpointMountPattern], ...]: - return (MountpointMountPattern,) + ) -> tuple[builtins.type[RcloneMountPattern], ...]: + return (RcloneMountPattern,) def in_container_adapter(self) -> InContainerMountAdapter: mount = self class _Adapter(InContainerMountAdapter): def validate(self, strategy: InContainerMountStrategy) -> None: - super().validate(strategy) + _ = strategy async def activate( self, @@ -497,7 +497,7 @@ def test_vercel_package_re_exports_backend_symbols(monkeypatch: pytest.MonkeyPat assert package_module.VercelCloudBucketMountStrategy.__name__ == ( "VercelCloudBucketMountStrategy" ) - assert package_module.VercelCloudBucketMountStrategy.credential_boundary == "client_opt_in" + assert package_module.VercelCloudBucketMountStrategy.credential_boundary == "inside_sandbox" assert package_module.VercelSandboxClient is vercel_module.VercelSandboxClient assert package_module.VercelSandboxSessionState is vercel_module.VercelSandboxSessionState @@ -557,22 +557,50 @@ def test_vercel_s3_mount_validates_credentials_and_lifecycle( @pytest.mark.asyncio -async def test_vercel_create_requires_explicit_s3_credential_exposure( +async def test_vercel_create_rejects_s3_credentials_before_provider_call( monkeypatch: pytest.MonkeyPatch, ) -> None: vercel_module = _load_vercel_module(monkeypatch) package_module = importlib.import_module("agents.extensions.sandbox.vercel") client = vercel_module.VercelSandboxClient() - with pytest.raises(MountConfigError, match="allow_s3_credential_exposure"): + with pytest.raises(MountConfigError, match="cannot be passed to a helper inside"): await client.create( manifest=_vercel_s3_manifest(package_module, credentials=True), - options=vercel_module.VercelSandboxClientOptions(), + options=vercel_module.VercelSandboxClientOptions( + allow_s3_credential_exposure=True, + ), ) assert _FakeAsyncSandbox.create_calls == [] +def test_vercel_from_state_rejects_trusted_s3_credentials( + monkeypatch: pytest.MonkeyPatch, +) -> None: + vercel_module = _load_vercel_module(monkeypatch) + package_module = importlib.import_module("agents.extensions.sandbox.vercel") + manifest = _vercel_s3_manifest(package_module) + state = vercel_module.VercelSandboxSessionState( + session_id="00000000-0000-0000-0000-000000000202", + manifest=manifest, + snapshot=NoopSnapshot(id="snapshot"), + sandbox_id="sandbox-with-credentials", + ) + + with pytest.raises(MountConfigError, match="cannot pass credentials"): + vercel_module.VercelSandboxSession.from_state( + state, + allow_s3_credential_exposure=True, + trusted_s3_mounts={ + "/workspace/remote": cast( + S3Mount, + _vercel_s3_manifest(package_module, credentials=True).entries["remote"], + ) + }, + ) + + @pytest.mark.asyncio async def test_vercel_create_revalidates_mutated_s3_mount( monkeypatch: pytest.MonkeyPatch, @@ -668,15 +696,15 @@ async def test_vercel_rejects_root_and_overlapping_s3_mounts( @pytest.mark.asyncio -async def test_vercel_s3_mount_is_create_time_only_and_credentials_are_not_serialized( +async def test_vercel_s3_mount_is_create_time_only_and_not_resumable( monkeypatch: pytest.MonkeyPatch, ) -> None: vercel_module = _load_vercel_module(monkeypatch) package_module = importlib.import_module("agents.extensions.sandbox.vercel") client = vercel_module.VercelSandboxClient() session = await client.create( - manifest=_vercel_s3_manifest(package_module, credentials=True), - options=vercel_module.VercelSandboxClientOptions(allow_s3_credential_exposure=True), + manifest=_vercel_s3_manifest(package_module), + options=vercel_module.VercelSandboxClientOptions(), ) sandbox = cast(_FakeAsyncSandbox, session._inner._sandbox) sandbox.command_results = { @@ -694,9 +722,6 @@ async def test_vercel_s3_mount_is_create_time_only_and_credentials_are_not_seria assert mount_call == ( "/usr/bin/mount-s3", { - "AWS_ACCESS_KEY_ID": "test-access-key", - "AWS_SECRET_ACCESS_KEY": "test-secret-key", - "AWS_SESSION_TOKEN": "test-session-token", "AWS_REGION": "us-west-2", }, True, @@ -708,9 +733,6 @@ async def test_vercel_s3_mount_is_create_time_only_and_credentials_are_not_seria payload = client.serialize_session_state(session.state) serialized = json.dumps(payload, sort_keys=True) - assert "test-access-key" not in serialized - assert "test-secret-key" not in serialized - assert "test-session-token" not in serialized assert "vercel_cloud_bucket" in serialized remote_mount = session.state.manifest.entries.pop("remote") @@ -1035,12 +1057,12 @@ async def test_vercel_s3_nested_activation_serializes_workspace_commands( @pytest.mark.asyncio -async def test_vercel_s3_manifest_sanitization_preserves_typed_environment( +async def test_vercel_s3_manifest_state_preserves_typed_environment( monkeypatch: pytest.MonkeyPatch, ) -> None: vercel_module = _load_vercel_module(monkeypatch) package_module = importlib.import_module("agents.extensions.sandbox.vercel") - manifest = _vercel_s3_manifest(package_module, credentials=True) + manifest = _vercel_s3_manifest(package_module) manifest.environment = Environment( value={ "DIRECT": StrEnvValue(value="direct-value"), @@ -1054,9 +1076,7 @@ async def test_vercel_s3_manifest_sanitization_preserves_typed_environment( client = vercel_module.VercelSandboxClient() session = await client.create( manifest=manifest, - options=vercel_module.VercelSandboxClientOptions( - allow_s3_credential_exposure=True, - ), + options=vercel_module.VercelSandboxClientOptions(), ) state_environment = session.state.manifest.environment.value @@ -1081,11 +1101,6 @@ async def test_vercel_s3_manifest_sanitization_preserves_typed_environment( }, } } - serialized = json.dumps(payload, sort_keys=True) - assert "test-access-key" not in serialized - assert "test-secret-key" not in serialized - assert "test-session-token" not in serialized - await session.shutdown() @@ -1857,111 +1872,6 @@ async def test_vercel_s3_mount_upgrades_mountpoint_below_minimum( assert dnf_call[1][-2:] == ["fuse", "mount-s3"] -@pytest.mark.asyncio -async def test_vercel_s3_mount_failure_redacts_full_activation_traceback( - monkeypatch: pytest.MonkeyPatch, -) -> None: - vercel_module = _load_vercel_module(monkeypatch) - package_module = importlib.import_module("agents.extensions.sandbox.vercel") - client = vercel_module.VercelSandboxClient() - session = await client.create( - manifest=_vercel_s3_manifest(package_module, credentials=True), - options=vercel_module.VercelSandboxClientOptions(allow_s3_credential_exposure=True), - ) - sandbox = cast(_FakeAsyncSandbox, session._inner._sandbox) - sandbox.command_results = { - "/usr/bin/test": [_FakeCommandFinished()], - "/usr/bin/rpm": [_FakeCommandFinished(stdout="1.21.0")], - "/usr/bin/find": [_FakeCommandFinished()], - } - secrets = ("test-access-key", "test-secret-key", "test-session-token") - provider_error = _FakeVercelSandboxRateLimitError(f"provider rejected {secrets[1]}") - original_run_command = sandbox.run_command - - def assert_activation_traceback_is_redacted(error: BaseException) -> None: - traceback = error.__traceback__ - while traceback is not None: - frame_path = Path(traceback.tb_frame.f_code.co_filename).as_posix() - if "/src/agents/" in frame_path: - locals_repr = repr(traceback.tb_frame.f_locals) - for secret in secrets: - assert secret not in locals_repr - traceback = traceback.tb_next - - async def fail_command( - cmd: str, - args: list[str] | None = None, - *, - cwd: str | None = None, - env: dict[str, str] | None = None, - sudo: bool = False, - ) -> _FakeCommandFinished: - if cmd == "/usr/bin/mount-s3": - assert env == { - "AWS_ACCESS_KEY_ID": secrets[0], - "AWS_SECRET_ACCESS_KEY": secrets[1], - "AWS_SESSION_TOKEN": secrets[2], - "AWS_REGION": "us-west-2", - } - raise provider_error - return await original_run_command(cmd, args, cwd=cwd, env=env, sudo=sudo) - - monkeypatch.setattr(sandbox, "run_command", fail_command) - - with pytest.raises(MountCommandError) as exc_info: - await session.start() - - assert exc_info.value.context["stderr"] == ( - "_FakeVercelSandboxRateLimitError: provider rejected REDACTED" - ) - assert exc_info.value.retryable is True - assert exc_info.value.__cause__ is None - assert exc_info.value.__context__ is None - assert provider_error.__traceback__ is None - assert provider_error.__cause__ is None - assert provider_error.__context__ is None - assert_activation_traceback_is_redacted(exc_info.value) - assert sandbox.stop_calls == 1 - - -@pytest.mark.asyncio -async def test_vercel_s3_mount_cancellation_redacts_full_activation_traceback( - monkeypatch: pytest.MonkeyPatch, -) -> None: - vercel_module = _load_vercel_module(monkeypatch) - package_module = importlib.import_module("agents.extensions.sandbox.vercel") - session = await vercel_module.VercelSandboxClient().create( - manifest=_vercel_s3_manifest(package_module, credentials=True), - options=vercel_module.VercelSandboxClientOptions(allow_s3_credential_exposure=True), - ) - sandbox = cast(_FakeAsyncSandbox, session._inner._sandbox) - sandbox.command_results = { - "/usr/bin/test": [_FakeCommandFinished()], - "/usr/bin/rpm": [_FakeCommandFinished(stdout="1.21.0")], - "/usr/bin/find": [_FakeCommandFinished()], - } - mount_started = asyncio.Event() - sandbox.command_started["/usr/bin/mount-s3"] = mount_started - sandbox.command_waiters["/usr/bin/mount-s3"] = asyncio.Event() - secrets = ("test-access-key", "test-secret-key", "test-session-token") - - start_task = asyncio.create_task(session.start()) - await asyncio.wait_for(mount_started.wait(), timeout=1) - start_task.cancel() - with pytest.raises(asyncio.CancelledError) as exc_info: - await start_task - - traceback = exc_info.value.__traceback__ - while traceback is not None: - frame_path = Path(traceback.tb_frame.f_code.co_filename).as_posix() - if "/src/agents/" in frame_path: - locals_repr = repr(traceback.tb_frame.f_locals) - for secret in secrets: - assert secret not in locals_repr - traceback = traceback.tb_next - assert sandbox.stop_calls == 1 - - @pytest.mark.asyncio async def test_vercel_exec_timeout_includes_output_collection_and_releases_mount_lock( monkeypatch: pytest.MonkeyPatch, @@ -2987,9 +2897,7 @@ async def test_vercel_tar_persistence_treats_mount_exclusions_as_literal_paths( ) -> None: vercel_module = _load_vercel_module(monkeypatch) snapshot = _MemorySnapshot(id="snapshot") - mount = _RecordingMount( - mount_strategy=InContainerMountStrategy(pattern=MountpointMountPattern()) - ) + mount = _RecordingMount(mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern())) sandbox = _FakeAsyncSandbox( sandbox_id="sandbox-mount-tar", files={ @@ -3026,6 +2934,7 @@ async def test_vercel_tar_persistence_treats_mount_exclusions_as_literal_paths( "cf", "/tmp/openai-agents-00000000000000000000000000000008.tar", "--no-wildcards", + "--exclude=./.sandbox-rclone-config", "--exclude=./cache[1]", ".", ], @@ -3042,9 +2951,7 @@ async def test_vercel_snapshot_persistence_tears_down_ephemeral_mounts( ) -> None: vercel_module = _load_vercel_module(monkeypatch) snapshot = _MemorySnapshot(id="snapshot") - mount = _RecordingMount( - mount_strategy=InContainerMountStrategy(pattern=MountpointMountPattern()) - ) + mount = _RecordingMount(mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern())) sandbox = _FakeAsyncSandbox( sandbox_id="sandbox-mount-snapshot", files={ diff --git a/tests/sandbox/integration_tests/_helpers.py b/tests/sandbox/integration_tests/_helpers.py index 2f411dd2b6..65569aaa2b 100644 --- a/tests/sandbox/integration_tests/_helpers.py +++ b/tests/sandbox/integration_tests/_helpers.py @@ -200,37 +200,25 @@ def build_manifest_with_all_entry_types(*, workspace_root: Path, source_root: Pa "repo": GitRepo(repo="openai/mock-sandbox-fixture", ref="main"), "mounts/s3": S3Mount( bucket="s3-bucket", - access_key_id="s3-access-key-id", - secret_access_key="s3-secret-access-key", mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), ), "mounts/gcs": GCSMount( bucket="gcs-bucket", - access_id="gcs-access-id", - secret_access_key="gcs-secret-access-key", mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), ), "mounts/r2": R2Mount( bucket="r2-bucket", account_id="r2-account-id", - access_key_id="r2-access-key-id", - secret_access_key="r2-secret-access-key", mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), ), "mounts/azure": AzureBlobMount( account="azure-account", container="azure-container", - account_key="azure-account-key", mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), ), }, ) - return manifest.with_in_container_mount_credential_exposure_allowed( - "mounts/s3", - "mounts/gcs", - "mounts/r2", - "mounts/azure", - ) + return manifest def manifest_entry_types(manifest: Manifest) -> set[str]: diff --git a/tests/sandbox/test_compatibility_guards.py b/tests/sandbox/test_compatibility_guards.py index 7ab0bf74ff..5fab666938 100644 --- a/tests/sandbox/test_compatibility_guards.py +++ b/tests/sandbox/test_compatibility_guards.py @@ -693,6 +693,7 @@ def test_optional_sandbox_client_options_positional_field_order_is_stable( "sandbox_url", "exposed_port_public", "exposed_port_url_ttl_s", + "mount_credential_cleanup_failed", ), ), ( diff --git a/tests/sandbox/test_docker.py b/tests/sandbox/test_docker.py index 005dbefd34..cd597d72db 100644 --- a/tests/sandbox/test_docker.py +++ b/tests/sandbox/test_docker.py @@ -46,6 +46,7 @@ InvalidManifestPathError, MountConfigError, PtySessionNotFoundError, + SandboxRuntimeError, WorkspaceArchiveReadError, WorkspaceArchiveWriteError, WorkspaceReadNotFoundError, @@ -55,6 +56,7 @@ from agents.sandbox.materialization import MaterializedFile from agents.sandbox.sandboxes.docker import ( DockerSandboxClient, + DockerSandboxClientOptions, DockerSandboxSession, DockerSandboxSessionState, ) @@ -231,23 +233,35 @@ class _FakeCreateDockerClient(_FakeDockerClient): def __init__(self, container: object) -> None: super().__init__() self.containers = _CreateRecorder(container) + self.volumes = _DeleteVolumeCollection({}) class _DeleteVolume: - def __init__(self) -> None: + def __init__(self, *, remove_error: Exception | None = None) -> None: self.remove_calls = 0 + self.remove_error = remove_error def remove(self) -> None: self.remove_calls += 1 + if self.remove_error is not None: + raise self.remove_error class _DeleteVolumeCollection: - def __init__(self, volumes: dict[str, _DeleteVolume]) -> None: + def __init__( + self, + volumes: dict[str, _DeleteVolume], + *, + get_error: Exception | None = None, + ) -> None: self._volumes = volumes + self._get_error = get_error self.get_calls: list[str] = [] def get(self, name: str) -> _DeleteVolume: self.get_calls.append(name) + if self._get_error is not None: + raise self._get_error try: return self._volumes[name] except KeyError as exc: @@ -1943,6 +1957,142 @@ async def test_docker_create_container_mounts_s3_with_volume_driver_ignoring_mou ] +@pytest.mark.asyncio +async def test_docker_create_container_sanitizes_credential_reflecting_errors( + monkeypatch: pytest.MonkeyPatch, +) -> None: + access_key = "DOCKER_CREATE_ACCESS_KEY_SENTINEL" + secret_key = "DOCKER_CREATE_SECRET_KEY_SENTINEL" + + class _ReflectingCreateRecorder(_CreateRecorder): + def create(self, **kwargs: object) -> object: + raise RuntimeError(repr(kwargs)) + + docker_client = _FakeCreateDockerClient(object()) + docker_client.containers = _ReflectingCreateRecorder(object()) + session_id = uuid.UUID("12345678-1234-5678-1234-567812345678") + expected_volume_name = "sandbox_12345678123456781234567812345678_ac6cdb3eb035_workspace_data" + volume = _DeleteVolume() + docker_client.volumes = _DeleteVolumeCollection({expected_volume_name: volume}) + client = DockerSandboxClient(docker_client=cast(object, docker_client)) + manifest = Manifest( + entries={ + "data": S3Mount( + bucket="bucket", + access_key_id=access_key, + secret_access_key=secret_key, + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ) + } + ) + monkeypatch.setattr(client, "image_exists", lambda _image: True) + monkeypatch.setattr("agents.sandbox.sandboxes.docker.uuid.uuid4", lambda: session_id) + + with pytest.raises(SandboxRuntimeError, match="failed to create") as exc_info: + await client.create( + manifest=manifest, + options=DockerSandboxClientOptions(image=DEFAULT_PYTHON_SANDBOX_IMAGE), + ) + + assert access_key not in repr(exc_info.value) + assert secret_key not in repr(exc_info.value) + assert exc_info.value.__cause__ is None + assert exc_info.value.__context__ is None + assert exc_info.value.context["container_cleanup_confirmed"] is False + assert exc_info.value.context["volume_cleanup_confirmed"] is True + assert docker_client.volumes.get_calls == [expected_volume_name] + assert volume.remove_calls == 1 + traceback = exc_info.value.__traceback__ + while traceback is not None: + if "/src/agents/" in Path(traceback.tb_frame.f_code.co_filename).as_posix(): + frame_locals = repr(traceback.tb_frame.f_locals) + assert access_key not in frame_locals + assert secret_key not in frame_locals + traceback = traceback.tb_next + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "cleanup_failure", + [None, "container_remove", "volume_get", "volume_remove"], +) +async def test_docker_create_cleans_up_after_sanitized_start_error( + monkeypatch: pytest.MonkeyPatch, + cleanup_failure: str | None, +) -> None: + secret_key = "DOCKER_START_SECRET_KEY_SENTINEL" + cleanup_secret = "DOCKER_CLEANUP_ERROR_SENTINEL" + + class _StartFailureContainer: + id = "created-container" + + def __init__(self, *, remove_error: Exception | None = None) -> None: + self.remove_calls: list[dict[str, object]] = [] + self.remove_error = remove_error + + def start(self) -> None: + raise RuntimeError(secret_key) + + def remove(self, **kwargs: object) -> None: + self.remove_calls.append(dict(kwargs)) + if self.remove_error is not None: + raise self.remove_error + + session_id = uuid.UUID("12345678-1234-5678-1234-567812345678") + expected_volume_name = "sandbox_12345678123456781234567812345678_ac6cdb3eb035_workspace_data" + cleanup_error = RuntimeError(cleanup_secret) + container = _StartFailureContainer( + remove_error=cleanup_error if cleanup_failure == "container_remove" else None + ) + volume = _DeleteVolume( + remove_error=cleanup_error if cleanup_failure == "volume_remove" else None + ) + docker_client = _FakeCreateDockerClient(container) + docker_client.volumes = _DeleteVolumeCollection( + {expected_volume_name: volume}, + get_error=cleanup_error if cleanup_failure == "volume_get" else None, + ) + client = DockerSandboxClient(docker_client=cast(object, docker_client)) + manifest = Manifest( + entries={ + "data": S3Mount( + bucket="bucket", + secret_access_key=secret_key, + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ) + } + ) + monkeypatch.setattr(client, "image_exists", lambda _image: True) + monkeypatch.setattr(uuid, "uuid4", lambda: session_id) + + with pytest.raises(SandboxRuntimeError, match="failed to start") as exc_info: + await client.create( + manifest=manifest, + options=DockerSandboxClientOptions(image=DEFAULT_PYTHON_SANDBOX_IMAGE), + ) + + assert secret_key not in repr(exc_info.value) + assert cleanup_secret not in repr(exc_info.value) + assert exc_info.value.__cause__ is None + assert exc_info.value.__context__ is None + assert container.remove_calls == [{"force": True}] + assert docker_client.volumes.get_calls == [expected_volume_name] + assert volume.remove_calls == (0 if cleanup_failure == "volume_get" else 1) + assert exc_info.value.context["container_cleanup_confirmed"] is ( + cleanup_failure != "container_remove" + ) + assert exc_info.value.context["volume_cleanup_confirmed"] is ( + cleanup_failure not in {"volume_get", "volume_remove"} + ) + traceback = exc_info.value.__traceback__ + while traceback is not None: + if "/src/agents/" in Path(traceback.tb_frame.f_code.co_filename).as_posix(): + frame_locals = repr(traceback.tb_frame.f_locals) + assert secret_key not in frame_locals + assert cleanup_secret not in frame_locals + traceback = traceback.tb_next + + @pytest.mark.asyncio async def test_docker_create_container_mounts_s3_with_rclone_driver( monkeypatch: pytest.MonkeyPatch, diff --git a/tests/sandbox/test_manifest.py b/tests/sandbox/test_manifest.py index 0f5eef6bc1..1c838ba766 100644 --- a/tests/sandbox/test_manifest.py +++ b/tests/sandbox/test_manifest.py @@ -9,14 +9,26 @@ from pydantic_core import PydanticSerializationError from agents.sandbox.entries import ( + AzureBlobMount, Dir, + DockerVolumeMountStrategy, File, + FuseMountPattern, GCSMount, InContainerMountStrategy, MountpointMountPattern, + RcloneMountPattern, + S3Mount, +) +from agents.sandbox.errors import InvalidManifestPathError, MountConfigError +from agents.sandbox.manifest import ( + EnvEntry, + Environment, + EnvValue, + Manifest, + StrEnvValue, + _coerce_manifest, ) -from agents.sandbox.errors import InvalidManifestPathError -from agents.sandbox.manifest import EnvEntry, Environment, EnvValue, Manifest, StrEnvValue from agents.sandbox.manifest_render import _truncate_manifest_description @@ -41,6 +53,72 @@ def _serialize_reference(self) -> dict[str, str]: return {"key": self.key} +def test_manifest_dict_unknown_entry_does_not_expose_credential_in_error() -> None: + sentinel = "UNKNOWN_ENTRY_SECRET_SENTINEL" + + with pytest.raises(TypeError) as exc_info: + _coerce_manifest( + { + "entries": { + "remote": { + "type": "future_mount", + "secret_access_key": sentinel, + } + } + }, + parameter_name="manifest", + ) + + assert sentinel not in str(exc_info.value) + assert sentinel not in repr(exc_info.value) + assert sentinel not in repr(exc_info.value.args) + traceback = exc_info.value.__traceback__ + while traceback is not None: + if "/src/agents/" in Path(traceback.tb_frame.f_code.co_filename).as_posix(): + assert sentinel not in repr(traceback.tb_frame.f_locals) + traceback = traceback.tb_next + + +@pytest.mark.parametrize("direct", [False, True]) +def test_malformed_mount_credential_does_not_expose_input_in_error(direct: bool) -> None: + sentinel = "MALFORMED_MOUNT_CREDENTIAL_SENTINEL" + + with pytest.raises(MountConfigError) as exc_info: + if direct: + S3Mount( + bucket="example", + secret_access_key=[sentinel], # type: ignore[arg-type] + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ) + else: + _coerce_manifest( + { + "entries": { + "remote": { + "type": "s3_mount", + "bucket": "example", + "secret_access_key": [sentinel], + "mount_strategy": {"type": "in_container"}, + } + } + }, + parameter_name="manifest", + ) + + error = exc_info.value + assert sentinel not in str(error) + assert sentinel not in repr(error) + assert sentinel not in repr(error.args) + assert sentinel not in repr(error.context) + assert error.__cause__ is None + assert error.__context__ is None + traceback = error.__traceback__ + while traceback is not None: + if "/src/agents/" in Path(traceback.tb_frame.f_code.co_filename).as_posix(): + assert sentinel not in repr(traceback.tb_frame.f_locals) + traceback = traceback.tb_next + + def test_manifest_rejects_nested_child_paths_that_escape_workspace() -> None: manifest = Manifest( entries={ @@ -114,6 +192,7 @@ def test_manifest_ephemeral_persistence_paths_include_resolved_mount_targets() - ) assert manifest.ephemeral_persistence_paths() == { + Path(".sandbox-mountpoint-env"), Path("logical"), Path("actual"), Path("dir/tmp.txt"), @@ -145,6 +224,156 @@ def test_manifest_ephemeral_mount_targets_sort_by_resolved_depth() -> None: ] +def test_manifest_persistence_uses_custom_blobfuse_cache_path() -> None: + manifest = Manifest( + entries={ + "remote": AzureBlobMount( + account="account", + container="container", + mount_strategy=InContainerMountStrategy( + pattern=FuseMountPattern(cache_path=Path("custom-cache")) + ), + ) + } + ) + + paths = manifest.ephemeral_persistence_paths() + + assert Path("custom-cache") in paths + assert Path(".sandbox-blobfuse-cache") not in paths + + +def test_manifest_persistence_excludes_azure_driver_credential_files() -> None: + manifest = Manifest( + entries={ + "remote": AzureBlobMount( + account="account", + container="container", + mount_strategy=DockerVolumeMountStrategy( + driver="rclone", + driver_options={ + "azureblob-client-certificate-path": "secrets/certificate.pem", + "azureblob-service-principal-file": "secrets/principal.json", + }, + ), + ) + } + ) + + paths = manifest.ephemeral_persistence_paths() + + assert Path("secrets/certificate.pem") in paths + assert Path("secrets/principal.json") in paths + + +def test_manifest_persistence_excludes_delimited_driver_credential_files() -> None: + manifest = Manifest( + entries={ + "remote": S3Mount( + bucket="example", + mount_strategy=DockerVolumeMountStrategy( + driver="rclone", + driver_options={ + "password.file": "secrets/password.txt", + "token.path": "secrets/token.json", + }, + ), + ) + } + ) + + paths = manifest.ephemeral_persistence_paths() + + assert Path("secrets/password.txt") in paths + assert Path("secrets/token.json") in paths + + +def test_manifest_persistence_excludes_s3_shared_credentials_file() -> None: + manifest = Manifest( + entries={ + "remote": S3Mount( + bucket="example", + mount_strategy=DockerVolumeMountStrategy( + driver="rclone", + driver_options={"s3-shared-credentials-file": "secrets/shared-credentials"}, + ), + ) + } + ) + + assert Path("secrets/shared-credentials") in manifest.ephemeral_persistence_paths() + + +def test_manifest_persistence_excludes_pinned_rclone_credential_files() -> None: + manifest = Manifest( + entries={ + "remote": S3Mount( + bucket="example", + mount_strategy=DockerVolumeMountStrategy( + driver="rclone", + driver_options={ + "client-key": "secrets/client-key.pem", + "metrics-client-ca": "secrets/metrics-ca.pem", + "smb-kerberos-ccache": "secrets/krb5.ccache", + }, + ), + ) + } + ) + + paths = manifest.ephemeral_persistence_paths() + + assert Path("secrets/client-key.pem") in paths + assert Path("secrets/metrics-ca.pem") in paths + assert Path("secrets/krb5.ccache") in paths + + +@pytest.mark.parametrize( + ("credential_filename", "option_name", "command"), + [ + ( + "credentials.json", + "sftp-ssh", + "sh -c 'ssh -i credentials.json \"$@\"' --", + ), + ( + "credentials.json", + "sftp-ssh", + "ssh -o 'IdentityFile credentials.json'", + ), + ("-credentials", "password-command", "cat -- -credentials"), + ( + "credentials.json", + "remote", + ':sftp,host=example.com,ssh="ssh -i credentials.json":/', + ), + ("credentials.json", "fs", "configured-remote:credentials.json"), + ], +) +def test_manifest_persistence_rejects_opaque_credential_commands_with_serialized_files( + credential_filename: str, + option_name: str, + command: str, +) -> None: + manifest = Manifest( + entries={ + credential_filename: File(content=b"SERIALIZED_COMMAND_FILE_SECRET_SENTINEL"), + "remote": S3Mount( + bucket="example", + mount_strategy=DockerVolumeMountStrategy( + driver="rclone", + driver_options={option_name: command}, + ), + ), + } + ) + + with pytest.raises(MountConfigError, match="opaque mount credential commands") as exc_info: + manifest.ephemeral_persistence_paths() + + assert "SERIALIZED_COMMAND_FILE_SECRET_SENTINEL" not in repr(exc_info.value) + + def test_manifest_ephemeral_mount_targets_normalize_non_escaping_mount_paths() -> None: mount = GCSMount( bucket="bucket", @@ -157,6 +386,7 @@ def test_manifest_ephemeral_mount_targets_normalize_non_escaping_mount_paths() - (mount, Path("/workspace/actual")), ] assert manifest.ephemeral_persistence_paths() == { + Path(".sandbox-mountpoint-env"), Path("logical"), Path("actual"), } diff --git a/tests/sandbox/test_mount_security.py b/tests/sandbox/test_mount_security.py index 2b44d698b8..93c674ae31 100644 --- a/tests/sandbox/test_mount_security.py +++ b/tests/sandbox/test_mount_security.py @@ -1,33 +1,68 @@ from __future__ import annotations from pathlib import Path -from typing import Any +from typing import Any, ClassVar, Literal import pytest +from agents.extensions.sandbox._rclone import _RCLONE_VERSION from agents.extensions.sandbox.daytona import DaytonaCloudBucketMountStrategy from agents.extensions.sandbox.e2b import E2BCloudBucketMountStrategy from agents.extensions.sandbox.runloop import RunloopCloudBucketMountStrategy -from agents.sandbox._mount_security import validate_mount_credential_boundaries +from agents.sandbox._mount_security import ( + sanitize_serialized_mount_credentials, + validate_mount_credential_boundaries, +) from agents.sandbox.entries import ( AzureBlobMount, BoxMount, DockerVolumeMountStrategy, File, + FuseMountPattern, GCSMount, InContainerMountStrategy, + MountpointMountPattern, R2Mount, RcloneMountPattern, + S3FilesMount, + S3FilesMountPattern, S3Mount, ) +from agents.sandbox.entries.mounts.base import ( + _AUDITED_RCLONE_VERSION, + _RCLONE_PINNED_CONNECTION_STRING_OPTIONS, + _RCLONE_PINNED_CREDENTIAL_COMMAND_OPTIONS, + _RCLONE_PINNED_CREDENTIAL_FILE_OPTIONS, + _RCLONE_PINNED_CREDENTIAL_OPTIONS, + _normalize_driver_option_name, +) from agents.sandbox.errors import MountConfigError -from agents.sandbox.manifest import Manifest, _coerce_manifest -from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxSessionState +from agents.sandbox.manifest import Environment, Manifest, StrEnvValue, _coerce_manifest from agents.sandbox.session import SandboxSessionState from agents.sandbox.session.manifest_application import ManifestApplier from agents.sandbox.snapshot import NoopSnapshot +class _MountSecuritySessionState(SandboxSessionState): + type: Literal["mount-security-test"] = "mount-security-test" + + +class _UndeclaredCredentialDockerStrategy(DockerVolumeMountStrategy): + type: Literal["test_undeclared_credential_docker"] = "test_undeclared_credential_docker" # type: ignore[assignment] + api_key: str | None = None + + +class _DeclaredCredentialDockerStrategy(DockerVolumeMountStrategy): + type: Literal["test_declared_credential_docker"] = "test_declared_credential_docker" # type: ignore[assignment] + api_key: str | None = None + _credential_field_names: ClassVar[frozenset[str]] = frozenset({"api_key"}) + + +class _UndeclaredCredentialS3Mount(S3Mount): + type: Literal["test_undeclared_credential_s3_mount"] = "test_undeclared_credential_s3_mount" # type: ignore[assignment] + api_key: str | None = None + + def _s3_manifest(*, strategy: Any, credentials: bool = True) -> Manifest: return Manifest( entries={ @@ -41,6 +76,45 @@ def _s3_manifest(*, strategy: Any, credentials: bool = True) -> Manifest: ) +def test_rclone_credential_option_audit_matches_managed_version() -> None: + assert _AUDITED_RCLONE_VERSION == _RCLONE_VERSION + assert { + "b2-key", + "client-key", + "crypt-password2", + "metrics-client-ca", + "sftp-key-file", + "sftp-pass", + "sftp-ssh", + "smb-kerberos-ccache", + "storj-access-grant", + } <= _RCLONE_PINNED_CREDENTIAL_OPTIONS + assert { + "client-key", + "metrics-client-ca", + "rc-htpasswd", + "smb-kerberos-ccache", + } <= _RCLONE_PINNED_CREDENTIAL_FILE_OPTIONS + assert { + "password-command", + "sftp-ssh", + "webdav-bearer-token-command", + } == _RCLONE_PINNED_CREDENTIAL_COMMAND_OPTIONS + assert { + "alias-remote", + "archive-remote", + "cache-remote", + "chunker-remote", + "combine-upstreams", + "compress-remote", + "crypt-remote", + "fs", + "hasher-remote", + "remote", + "union-upstreams", + } == _RCLONE_PINNED_CONNECTION_STRING_OPTIONS + + def test_credentialed_in_container_mount_is_rejected_without_secret_values() -> None: manifest = _s3_manifest(strategy=InContainerMountStrategy(pattern=RcloneMountPattern())) @@ -59,6 +133,208 @@ def test_credentialed_in_container_mount_is_rejected_without_secret_values() -> assert "secret-key" not in rendered +def test_typed_mount_rejects_undeclared_credential_like_fields() -> None: + sentinel = "UNDECLARED_MOUNT_API_KEY_SENTINEL" + manifest = Manifest( + entries={ + "remote": _UndeclaredCredentialS3Mount( + bucket="example", + api_key=sentinel, + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ) + } + ) + + with pytest.raises(MountConfigError) as exc_info: + validate_mount_credential_boundaries(manifest) + + assert exc_info.value.context["credential_fields"] == ["api_key"] + assert sentinel not in repr(exc_info.value) + + +@pytest.mark.parametrize( + "strategy", + [ + InContainerMountStrategy(pattern=RcloneMountPattern()), + DockerVolumeMountStrategy(driver="rclone"), + ], +) +def test_mount_rejects_inline_endpoint_url_credentials(strategy: Any) -> None: + sentinel = "INLINE_ENDPOINT_PASSWORD_SENTINEL" + manifest = Manifest( + entries={ + "remote": S3Mount( + bucket="example", + endpoint_url=f"https://user:{sentinel}@storage.example", + mount_strategy=strategy, + ) + } + ) + + with pytest.raises(MountConfigError) as exc_info: + validate_mount_credential_boundaries(manifest) + + assert exc_info.value.context["credential_fields"] == ["endpoint_url"] + assert sentinel not in repr(exc_info.value) + + +def test_serialized_mount_rejects_inline_endpoint_url_credentials() -> None: + sentinel = "SERIALIZED_ENDPOINT_PASSWORD_SENTINEL" + manifest = Manifest( + entries={ + "remote": S3Mount( + bucket="example", + endpoint_url=f"https://user:{sentinel}@storage.example", + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ) + } + ) + + with pytest.raises( + ValueError, + match="Persisted mount endpoint URLs cannot contain inline credentials", + ) as exc_info: + sanitize_serialized_mount_credentials({"manifest": manifest.model_dump(mode="json")}) + + assert sentinel not in repr(exc_info.value) + + +def test_mount_rejects_signed_endpoint_query_in_typed_and_raw_state() -> None: + sentinel = "SIGNED_ENDPOINT_SECRET_SENTINEL" + manifest = Manifest( + entries={ + "remote": S3Mount( + bucket="example", + endpoint_url=( + "https://storage.example/bucket" + f"?X-Amz-Credential=key&X-Amz-Signature={sentinel}" + ), + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ) + } + ) + + with pytest.raises(MountConfigError) as typed_error: + validate_mount_credential_boundaries(manifest) + assert typed_error.value.context["credential_fields"] == ["endpoint_url"] + + with pytest.raises( + ValueError, + match="Persisted mount endpoint URLs cannot contain inline credentials", + ) as raw_error: + sanitize_serialized_mount_credentials({"manifest": manifest.model_dump(mode="json")}) + + assert sentinel not in repr(typed_error.value) + assert sentinel not in repr(raw_error.value) + + +def test_r2_custom_domain_rejects_inline_credentials_in_typed_and_raw_state() -> None: + sentinel = "R2_CUSTOM_DOMAIN_PASSWORD_SENTINEL" + manifest = Manifest( + entries={ + "remote": R2Mount( + bucket="example", + account_id="account", + custom_domain=f"https://user:{sentinel}@storage.example", + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ) + } + ) + + with pytest.raises(MountConfigError) as typed_error: + validate_mount_credential_boundaries(manifest) + assert typed_error.value.context["credential_fields"] == ["custom_domain"] + + with pytest.raises( + ValueError, + match="Persisted mount endpoint URLs cannot contain inline credentials", + ) as raw_error: + sanitize_serialized_mount_credentials({"manifest": manifest.model_dump(mode="json")}) + + assert sentinel not in repr(typed_error.value) + assert sentinel not in repr(raw_error.value) + + +@pytest.mark.parametrize( + "environment_name", + [ + "AWS_SECRET_ACCESS_KEY", + "AWS_SHARED_CREDENTIALS_FILE", + "GOOGLE_APPLICATION_CREDENTIALS", + ], +) +@pytest.mark.parametrize( + "environment_value", + [ + "AMBIENT_ENVIRONMENT_SECRET_SENTINEL", + StrEnvValue(value="AMBIENT_ENVIRONMENT_SECRET_SENTINEL"), + ], +) +def test_in_container_mount_rejects_credential_like_manifest_environment( + environment_name: str, + environment_value: str | StrEnvValue, +) -> None: + manifest = Manifest( + environment=Environment(value={environment_name: environment_value}), + entries={ + "remote": S3Mount( + bucket="example", + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ) + }, + ) + + with pytest.raises(MountConfigError) as typed_error: + validate_mount_credential_boundaries(manifest) + assert typed_error.value.context == { + "mount_paths": ["/workspace/remote"], + "credential_environment_names": [environment_name], + } + + with pytest.raises( + ValueError, + match="Persisted credential-like manifest environment variables", + ) as raw_error: + sanitize_serialized_mount_credentials({"manifest": manifest.model_dump(mode="json")}) + + assert "AMBIENT_ENVIRONMENT_SECRET_SENTINEL" not in repr(typed_error.value) + assert "AMBIENT_ENVIRONMENT_SECRET_SENTINEL" not in repr(raw_error.value) + + +@pytest.mark.parametrize( + "pattern", + [ + RcloneMountPattern(remote_name=":s3"), + MountpointMountPattern( + options=MountpointMountPattern.MountpointOptions( + endpoint_url="https://user:PATTERN_SECRET_SENTINEL@storage.example" + ) + ), + ], +) +def test_mount_pattern_rejects_inline_credentials_in_typed_and_raw_state( + pattern: RcloneMountPattern | MountpointMountPattern, +) -> None: + manifest = _s3_manifest( + strategy=InContainerMountStrategy(pattern=pattern), + credentials=False, + ) + + with pytest.raises(MountConfigError, match="patterns cannot contain inline") as typed_error: + validate_mount_credential_boundaries(manifest) + + with pytest.raises( + ValueError, + match="Persisted mount patterns cannot contain inline", + ) as raw_error: + sanitize_serialized_mount_credentials({"manifest": manifest.model_dump(mode="json")}) + + assert "PATTERN_ACCESS_SENTINEL" not in repr(typed_error.value) + assert "PATTERN_SECRET_SENTINEL" not in repr(typed_error.value) + assert "PATTERN_ACCESS_SENTINEL" not in repr(raw_error.value) + assert "PATTERN_SECRET_SENTINEL" not in repr(raw_error.value) + + @pytest.mark.parametrize( ("mount", "credential_fields"), [ @@ -117,6 +393,116 @@ def test_credentialed_in_container_mount_is_rejected_without_secret_values() -> ), ("mount_strategy.pattern.config_file_path",), ), + ( + S3Mount( + bucket="example", + mount_strategy=DockerVolumeMountStrategy( + driver="rclone", + driver_options={ + "s3-secret-access-key": "secret", + "vfs-cache-mode": "off", + }, + ), + ), + ("mount_strategy.driver_options.s3-secret-access-key",), + ), + ( + S3Mount( + bucket="example", + mount_strategy=DockerVolumeMountStrategy( + driver="rclone", + driver_options={"api_key": "secret"}, + ), + ), + ("mount_strategy.driver_options.api-key",), + ), + ( + S3Mount( + bucket="example", + mount_strategy=DockerVolumeMountStrategy( + driver="rclone", + driver_options={ + "s3-profile": "profile", + "s3-shared-credentials-file": "credentials", + "s3-sse-customer-key": "secret", + "s3-sse-customer-key-base64": "encoded-secret", + }, + ), + ), + ( + "mount_strategy.driver_options.s3-profile", + "mount_strategy.driver_options.s3-shared-credentials-file", + "mount_strategy.driver_options.s3-sse-customer-key", + "mount_strategy.driver_options.s3-sse-customer-key-base64", + ), + ), + ( + AzureBlobMount( + account="account", + container="container", + mount_strategy=DockerVolumeMountStrategy( + driver="rclone", + driver_options={ + "azureblob-client-certificate-pem": "secret", + "azureblob-client-certificate-password": "secret", + "azureblob-client-certificate-path": "certificate.pem", + "azureblob-client-secret": "secret", + "azureblob-connection-string": "secret", + "azureblob-key": "secret", + "azureblob-password": "secret", + "azureblob-sas-url": "secret", + "azureblob-service-principal-file": "principal.json", + }, + ), + ), + ( + "mount_strategy.driver_options.azureblob-client-certificate-password", + "mount_strategy.driver_options.azureblob-client-certificate-path", + "mount_strategy.driver_options.azureblob-client-certificate-pem", + "mount_strategy.driver_options.azureblob-client-secret", + "mount_strategy.driver_options.azureblob-connection-string", + "mount_strategy.driver_options.azureblob-key", + "mount_strategy.driver_options.azureblob-password", + "mount_strategy.driver_options.azureblob-sas-url", + "mount_strategy.driver_options.azureblob-service-principal-file", + ), + ), + ( + S3Mount( + bucket="example", + mount_strategy=DockerVolumeMountStrategy( + driver="rclone", + driver_options={"sftp-ssh": "sshpass -p secret ssh"}, + ), + ), + ("mount_strategy.driver_options.sftp-ssh",), + ), + ( + S3Mount( + bucket="example", + mount_strategy=DockerVolumeMountStrategy( + driver="rclone", + driver_options={"http-proxy": "https://user:proxy-secret@proxy.example"}, + ), + ), + ("mount_strategy.driver_options.http-proxy",), + ), + ( + S3Mount( + bucket="example", + mount_strategy=DockerVolumeMountStrategy( + driver="rclone", + driver_options={ + "fs": "configured:bucket", + "remote": ":s3,secret_access_key=secret:bucket", + }, + ), + ), + ( + "mount_strategy.driver_options.fs", + "mount_strategy.driver_options.remote", + ), + ), ], ) def test_provider_credential_sources_are_classified( @@ -126,6 +512,103 @@ def test_provider_credential_sources_are_classified( assert mount._configured_credential_fields() == credential_fields +@pytest.mark.parametrize( + "option_name", + [ + "apiKey", + "apikey", + "apiKeyFile", + "apikeyfile", + "accessKey", + "secretKey", + "api.key", + "secret.access.key", + "password.file", + "token.path", + ], +) +def test_compact_and_camel_case_docker_credential_options_are_classified( + option_name: str, +) -> None: + mount = S3Mount( + bucket="example", + mount_strategy=DockerVolumeMountStrategy( + driver="rclone", + driver_options={option_name: "secret"}, + ), + ) + + assert mount._configured_credential_fields() == ( + f"mount_strategy.driver_options.{_normalize_driver_option_name(option_name)}", + ) + + +@pytest.mark.parametrize("option_name", sorted(_RCLONE_PINNED_CONNECTION_STRING_OPTIONS)) +def test_rclone_connection_authority_options_are_classified(option_name: str) -> None: + mount = S3Mount( + bucket="example", + mount_strategy=DockerVolumeMountStrategy( + driver="rclone", + driver_options={option_name: "configured-remote:bucket"}, + ), + ) + + assert mount._configured_credential_fields() == ( + f"mount_strategy.driver_options.{option_name}", + ) + + +@pytest.mark.parametrize( + "driver_options", + [ + {"dump": "auth"}, + {"dump-bodies": "true"}, + {"dump-headers": "1"}, + {"rc": "true"}, + ], +) +def test_rclone_docker_exposure_options_are_rejected_before_side_effects( + driver_options: dict[str, str], +) -> None: + manifest = _s3_manifest( + credentials=False, + strategy=DockerVolumeMountStrategy( + driver="rclone", + driver_options=driver_options, + ), + ) + + with pytest.raises(MountConfigError, match="cannot enable"): + validate_mount_credential_boundaries(manifest) + + +@pytest.mark.parametrize( + "driver_options", + [ + {"dump": "filters"}, + {"dump-bodies": "false"}, + {"dump-headers": "0"}, + {"http-proxy": "http://proxy.example:8080"}, + {"rc": "false"}, + ], +) +def test_benign_value_sensitive_rclone_docker_options_are_preserved( + driver_options: dict[str, str], +) -> None: + manifest = _s3_manifest( + credentials=False, + strategy=DockerVolumeMountStrategy( + driver="rclone", + driver_options=driver_options, + ), + ) + + validate_mount_credential_boundaries(manifest) + mount = manifest.entries["remote"] + assert isinstance(mount, S3Mount) + assert mount._configured_credential_fields() == () + + @pytest.mark.parametrize( "strategy", [ @@ -153,14 +636,13 @@ def test_hosted_rclone_config_paths_are_rejected_and_redacted(strategy: Any) -> with pytest.raises(MountConfigError): validate_mount_credential_boundaries(manifest) - trusted = manifest.with_in_container_mount_credential_exposure_allowed("remote") - redacted, paths = redact_manifest_mount_credentials(trusted) + redacted, paths = redact_manifest_mount_credentials(manifest) redacted_mount = redacted.entries["remote"] assert isinstance(redacted_mount, S3Mount) redacted_pattern = getattr(redacted_mount.mount_strategy, "pattern", None) assert isinstance(redacted_pattern, RcloneMountPattern) assert redacted_pattern.config_file_path is None - rebound = rebind_manifest_mount_credentials(redacted, trusted, paths) + rebound = rebind_manifest_mount_credentials(redacted, manifest, paths) rebound_mount = rebound.entries["remote"] assert isinstance(rebound_mount, S3Mount) rebound_pattern = getattr(rebound_mount.mount_strategy, "pattern", None) @@ -179,7 +661,7 @@ def test_manifest_entry_cannot_supply_rclone_credential_config() -> None: ), ), } - ).with_in_container_mount_credential_exposure_allowed("remote") + ) with pytest.raises(MountConfigError, match="serialized manifest entry") as exc_info: validate_mount_credential_boundaries(manifest) @@ -187,6 +669,243 @@ def test_manifest_entry_cannot_supply_rclone_credential_config() -> None: assert "RCLONE_SECRET_SENTINEL" not in repr(exc_info.value) +@pytest.mark.parametrize( + "mount", + [ + GCSMount( + bucket="example", + service_account_file="credentials.json", + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ), + BoxMount( + box_config_file="/workspace/credentials.json", + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ), + GCSMount( + bucket="example", + mount_strategy=DockerVolumeMountStrategy( + driver="rclone", + driver_options={"gcs-service-account-file": "credentials.json"}, + ), + ), + S3Mount( + bucket="example", + mount_strategy=DockerVolumeMountStrategy( + driver="rclone", + driver_options={"api_key_file": "credentials.json"}, + ), + ), + S3Mount( + bucket="example", + mount_strategy=DockerVolumeMountStrategy( + driver="rclone", + driver_options={"apiKeyFile": "credentials.json"}, + ), + ), + GCSMount( + bucket="example", + mount_strategy=DockerVolumeMountStrategy( + driver="rclone", + driver_options={"serviceaccountfile": "credentials.json"}, + ), + ), + AzureBlobMount( + account="account", + container="container", + mount_strategy=DockerVolumeMountStrategy( + driver="rclone", + driver_options={"azureblob-service-principal-file": "credentials.json"}, + ), + ), + AzureBlobMount( + account="account", + container="container", + mount_strategy=DockerVolumeMountStrategy( + driver="rclone", + driver_options={"azureblob-client-certificate-path": "credentials.json"}, + ), + ), + S3Mount( + bucket="example", + mount_strategy=DockerVolumeMountStrategy( + driver="rclone", + driver_options={"password.file": "credentials.json"}, + ), + ), + S3Mount( + bucket="example", + mount_strategy=DockerVolumeMountStrategy( + driver="rclone", + driver_options={"token.path": "credentials.json"}, + ), + ), + S3Mount( + bucket="example", + mount_strategy=DockerVolumeMountStrategy( + driver="rclone", + driver_options={"s3-shared-credentials-file": "credentials.json"}, + ), + ), + S3Mount( + bucket="example", + mount_strategy=DockerVolumeMountStrategy( + driver="rclone", + driver_options={"sftp-key-file": "credentials.json"}, + ), + ), + S3Mount( + bucket="example", + mount_strategy=DockerVolumeMountStrategy( + driver="rclone", + driver_options={"client-key": "credentials.json"}, + ), + ), + S3Mount( + bucket="example", + mount_strategy=DockerVolumeMountStrategy( + driver="rclone", + driver_options={"smb-kerberos-ccache": "credentials.json"}, + ), + ), + S3Mount( + bucket="example", + mount_strategy=DockerVolumeMountStrategy( + driver="rclone", + driver_options={"sftp-ssh": "ssh -i credentials.json"}, + ), + ), + S3Mount( + bucket="example", + mount_strategy=DockerVolumeMountStrategy( + driver="rclone", + driver_options={"sftp-ssh": "sh -c 'ssh -i credentials.json \"$@\"' --"}, + ), + ), + S3Mount( + bucket="example", + mount_strategy=DockerVolumeMountStrategy( + driver="rclone", + driver_options={"sftp-ssh": "ssh -o 'IdentityFile credentials.json'"}, + ), + ), + S3Mount( + bucket="example", + mount_strategy=DockerVolumeMountStrategy( + driver="rclone", + driver_options={"password-command": "cat -- credentials.json"}, + ), + ), + S3Mount( + bucket="example", + mount_strategy=DockerVolumeMountStrategy( + driver="rclone", + driver_options={"remote": ":sftp,ssh=cat credentials.json:/"}, + ), + ), + S3Mount( + bucket="example", + mount_strategy=DockerVolumeMountStrategy( + driver="rclone", + driver_options={"fs": "configured-remote:credentials.json"}, + ), + ), + ], + ids=[ + "gcs-field", + "box-field", + "docker-driver-option", + "generic-api-key-file", + "camel-api-key-file", + "compact-service-account-file", + "azure-service-principal-file", + "azure-client-certificate-path", + "dot-password-file", + "dot-token-path", + "s3-shared-credentials-file", + "sftp-key-file", + "client-key", + "smb-kerberos-ccache", + "sftp-ssh-command", + "sftp-ssh-nested-command", + "sftp-ssh-openssh-option", + "password-command", + "remote-connection-string", + "fs-configured-remote", + ], +) +def test_manifest_entry_cannot_supply_any_mount_credential_file(mount: Any) -> None: + manifest = Manifest( + entries={ + "credentials.json": File(content=b"MANIFEST_CREDENTIAL_SECRET_SENTINEL"), + "remote": mount, + } + ) + + with pytest.raises(MountConfigError, match="serialized manifest") as exc_info: + validate_mount_credential_boundaries(manifest) + + assert "MANIFEST_CREDENTIAL_SECRET_SENTINEL" not in repr(exc_info.value) + + +def test_opaque_rclone_command_is_not_parsed_without_serialized_files() -> None: + manifest = Manifest( + entries={ + "remote": S3Mount( + bucket="example", + mount_strategy=DockerVolumeMountStrategy( + driver="rclone", + driver_options={"sftp-ssh": "ssh -o 'IdentityFile key.pem'"}, + ), + ) + } + ) + + validate_mount_credential_boundaries(manifest) + assert manifest.ephemeral_persistence_paths() + + +@pytest.mark.parametrize("option_name", ["remote", "fs", "crypt-remote", "union-upstreams"]) +def test_parameterized_rclone_connection_strings_are_rejected(option_name: str) -> None: + sentinel = "RCLONE_CONNECTION_STRING_SECRET_SENTINEL" + manifest = Manifest( + entries={ + "remote": S3Mount( + bucket="example", + mount_strategy=DockerVolumeMountStrategy( + driver="rclone", + driver_options={ + option_name: ( + f":s3,access_key_id=inline-access,secret_access_key={sentinel}:bucket" + ) + }, + ), + ) + } + ) + + with pytest.raises(MountConfigError, match="cannot contain inline parameters") as exc_info: + validate_mount_credential_boundaries(manifest) + + assert sentinel not in repr(exc_info.value) + + +@pytest.mark.parametrize("option_name", ["remote", "fs"]) +def test_unparameterized_rclone_remote_options_remain_supported(option_name: str) -> None: + manifest = Manifest( + entries={ + "remote": S3Mount( + bucket="example", + mount_strategy=DockerVolumeMountStrategy( + driver="rclone", + driver_options={option_name: "configured-remote:bucket"}, + ), + ) + } + ) + + validate_mount_credential_boundaries(manifest) + + def test_credentialless_in_container_mount_remains_supported() -> None: manifest = _s3_manifest( strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), @@ -196,25 +915,471 @@ def test_credentialless_in_container_mount_remains_supported() -> None: validate_mount_credential_boundaries(manifest) +@pytest.mark.parametrize( + "extra_args", + [ + ["--config", "RCLONE_EXTRA_ARGS_SECRET_SENTINEL"], + ["--s3-secret-access-key=RCLONE_EXTRA_ARGS_SECRET_SENTINEL"], + ["--s3-shared-credentials-file", "RCLONE_EXTRA_ARGS_SECRET_SENTINEL"], + ["--s3-profile=RCLONE_EXTRA_ARGS_SECRET_SENTINEL"], + ["--header", "Authorization: Bearer RCLONE_EXTRA_ARGS_SECRET_SENTINEL"], + ["--header-upload=X-Api-Key: RCLONE_EXTRA_ARGS_SECRET_SENTINEL"], + ["--header-download", "X-Api-Key: RCLONE_EXTRA_ARGS_SECRET_SENTINEL"], + ["--http-headers", "Authorization,RCLONE_EXTRA_ARGS_SECRET_SENTINEL"], + ["--http-proxy", "https://user:RCLONE_EXTRA_ARGS_SECRET_SENTINEL@proxy"], + ["--client-cert", "RCLONE_EXTRA_ARGS_SECRET_SENTINEL"], + ["--client-key", "RCLONE_EXTRA_ARGS_SECRET_SENTINEL"], + ["--client-pass=RCLONE_EXTRA_ARGS_SECRET_SENTINEL"], + ["--dump=auth"], + ["--dump-bodies"], + ["--dump-headers"], + ["--s3-sse-customer-key=RCLONE_EXTRA_ARGS_SECRET_SENTINEL"], + ["--s3-sse-customer-key-base64", "RCLONE_EXTRA_ARGS_SECRET_SENTINEL"], + ["--azureblob-client-certificate-pem", "RCLONE_EXTRA_ARGS_SECRET_SENTINEL"], + ["--rc", "--rc-no-auth"], + ["--rc=true", "--rc-no-auth"], + ["--sftp-pass=RCLONE_EXTRA_ARGS_SECRET_SENTINEL"], + ["--b2-key", "RCLONE_EXTRA_ARGS_SECRET_SENTINEL"], + ["--sftp-key-file=RCLONE_EXTRA_ARGS_SECRET_SENTINEL"], + ["--crypt-password2", "RCLONE_EXTRA_ARGS_SECRET_SENTINEL"], + ["--storj-access-grant=RCLONE_EXTRA_ARGS_SECRET_SENTINEL"], + ["--sftp-ssh", "sshpass -p RCLONE_EXTRA_ARGS_SECRET_SENTINEL ssh"], + ["--sftp-ssh=ssh -i /workspace/RCLONE_EXTRA_ARGS_SECRET_SENTINEL"], + ], +) +def test_rclone_extra_args_cannot_override_credential_sources(extra_args: list[str]) -> None: + manifest = _s3_manifest( + strategy=InContainerMountStrategy(pattern=RcloneMountPattern(extra_args=extra_args)), + credentials=False, + ) + + with pytest.raises(MountConfigError, match="extra_args cannot configure") as exc_info: + validate_mount_credential_boundaries(manifest) + + assert "RCLONE_EXTRA_ARGS_SECRET_SENTINEL" not in repr(exc_info.value) + + +@pytest.mark.parametrize( + "extra_args", + [ + ["--vfs-cache-mode", "full"], + ["--http-proxy", "http://proxy.example:8080"], + ["--http-proxy=http://proxy.example:8080"], + ["--dump", "filters"], + ["--dump=filters"], + ["--rc=false"], + ["--dump-bodies=false"], + ["--dump-headers=0"], + ], +) +def test_benign_rclone_extra_args_remain_supported(extra_args: list[str]) -> None: + manifest = _s3_manifest( + strategy=InContainerMountStrategy(pattern=RcloneMountPattern(extra_args=extra_args)), + credentials=False, + ) + + validate_mount_credential_boundaries(manifest) + assert extra_args[0] in str(manifest.model_dump(mode="json")) + + +def test_rclone_extra_args_cannot_reference_manifest_shared_credentials_file() -> None: + sentinel = "RCLONE_SHARED_CREDENTIALS_FILE_SENTINEL" + manifest = Manifest( + entries={ + "credentials": File(content=sentinel.encode()), + "remote": S3Mount( + bucket="example", + mount_strategy=InContainerMountStrategy( + pattern=RcloneMountPattern( + extra_args=["--s3-shared-credentials-file", "credentials"] + ) + ), + ), + } + ) + + with pytest.raises(MountConfigError, match="extra_args cannot configure") as exc_info: + validate_mount_credential_boundaries(manifest) + + assert sentinel not in repr(exc_info.value) + + +def test_raw_unregistered_mount_opaque_fields_are_removed_before_validation() -> None: + sentinel = "UNREGISTERED_MOUNT_OPAQUE_AUTH_SENTINEL" + sanitized, credential_paths = sanitize_serialized_mount_credentials( + { + "manifest": { + "entries": { + "remote": { + "type": "late_custom_mount", + "description": "Preserved structural metadata", + "opaque_auth_blob": sentinel, + "mount_strategy": { + "type": "in_container", + "pattern": {"type": "rclone"}, + }, + } + } + } + } + ) + + assert sentinel not in str(sanitized) + manifest = sanitized["manifest"] + assert isinstance(manifest, dict) + entries = manifest["entries"] + assert isinstance(entries, dict) + remote = entries["remote"] + assert isinstance(remote, dict) + assert remote["description"] == "Preserved structural metadata" + assert "opaque_auth_blob" not in remote + assert credential_paths == {"remote": ("mount.raw_credential",)} + + +@pytest.mark.parametrize("invalid_type_location", ["mount", "strategy"]) +def test_raw_unregistered_mount_requires_string_type_fields( + invalid_type_location: str, +) -> None: + sentinel = "UNREGISTERED_MOUNT_TYPE_SENTINEL" + mount_type: object = "late_custom_mount" + strategy_type: object = "late_custom_strategy" + if invalid_type_location == "mount": + mount_type = {"secret": sentinel} + else: + strategy_type = {"secret": sentinel} + + payload: dict[str, object] = { + "manifest": { + "entries": { + "remote": { + "type": mount_type, + "mount_strategy": {"type": strategy_type}, + } + } + } + } + + with pytest.raises(ValueError, match="type must be a string") as exc_info: + sanitize_serialized_mount_credentials(payload) + + assert sentinel not in repr(exc_info.value) + + +@pytest.mark.parametrize("mount_type", ["late_custom_mount", "s3_mount"]) +def test_raw_unregistered_mount_strategy_fields_are_removed_before_validation( + mount_type: str, +) -> None: + sentinel = "UNREGISTERED_MOUNT_STRATEGY_AUTH_SENTINEL" + sanitized, credential_paths = sanitize_serialized_mount_credentials( + { + "manifest": { + "entries": { + "remote": { + "type": mount_type, + "mount_strategy": { + "type": "late_custom_strategy", + "api_key": sentinel, + "config": {"token": sentinel}, + }, + } + } + } + } + ) + + assert sentinel not in str(sanitized) + manifest = sanitized["manifest"] + assert isinstance(manifest, dict) + entries = manifest["entries"] + assert isinstance(entries, dict) + remote = entries["remote"] + assert isinstance(remote, dict) + assert remote["mount_strategy"] == {"type": "late_custom_strategy"} + assert credential_paths == {"remote": ("mount.raw_credential",)} + + +def test_registered_strategy_generic_credentials_are_removed_before_validation() -> None: + sentinel = "REGISTERED_STRATEGY_AUTH_SENTINEL" + sanitized, credential_paths = sanitize_serialized_mount_credentials( + { + "manifest": { + "entries": { + "remote": { + "type": "s3_mount", + "bucket": "example", + "mount_strategy": { + "type": "test_undeclared_credential_docker", + "driver": "rclone", + "api_key": sentinel, + }, + } + } + } + } + ) + + assert sentinel not in str(sanitized) + assert credential_paths == {"remote": ("mount.raw_credential",)} + + +def test_registered_strategy_requires_explicit_credential_metadata() -> None: + sentinel = "UNDECLARED_STRATEGY_CREDENTIAL_SENTINEL" + manifest = _s3_manifest( + credentials=False, + strategy=_UndeclaredCredentialDockerStrategy( + driver="rclone", + api_key=sentinel, + ), + ) + + with pytest.raises(MountConfigError, match="must be declared") as exc_info: + validate_mount_credential_boundaries(manifest) + + assert sentinel not in repr(exc_info.value) + + +def test_registered_strategy_accepts_declared_external_credentials() -> None: + manifest = _s3_manifest( + credentials=False, + strategy=_DeclaredCredentialDockerStrategy( + driver="rclone", + api_key="declared-strategy-secret", + ), + ) + + validate_mount_credential_boundaries(manifest) + + +def test_raw_registered_mount_rejects_nested_reserved_driver_options() -> None: + sentinel = "NESTED_DRIVER_OPTIONS_SECRET_SENTINEL" + payload: dict[str, object] = { + "manifest": { + "entries": { + "remote": { + "type": "s3_mount", + "bucket": "example", + "metadata": {"driver_options": {"s3-secret-access-key": sentinel}}, + "mount_strategy": { + "type": "docker_volume", + "driver": "rclone", + }, + } + } + } + } + + with pytest.raises(ValueError, match="driver_options has an ambiguous location") as exc_info: + sanitize_serialized_mount_credentials(payload) + + assert sentinel not in repr(exc_info.value) + + +def test_raw_unregistered_mount_cannot_reference_manifest_credential_file() -> None: + sentinel = "UNREGISTERED_MOUNT_FILE_SECRET_SENTINEL" + payload: dict[str, object] = { + "manifest": { + "entries": { + "credentials.json": {"type": "file", "content": sentinel}, + "remote": { + "type": "late_custom_mount", + "opaque_config": "credentials.json", + "mount_strategy": { + "type": "in_container", + "pattern": {"type": "rclone"}, + }, + }, + } + } + } + + with pytest.raises(ValueError, match="must not be a manifest entry") as exc_info: + sanitize_serialized_mount_credentials(payload) + + assert sentinel not in repr(exc_info.value) + + +def test_raw_unregistered_strategy_on_registered_mount_cannot_reference_manifest_file() -> None: + sentinel = "UNREGISTERED_STRATEGY_FILE_SECRET_SENTINEL" + payload: dict[str, object] = { + "manifest": { + "entries": { + "credentials.json": {"type": "file", "content": sentinel}, + "remote": { + "type": "s3_mount", + "bucket": "example", + "mount_strategy": { + "type": "late_custom_strategy", + "config": {"credentials_file": "credentials.json"}, + }, + }, + } + } + } + + with pytest.raises(ValueError, match="must not be a manifest entry") as exc_info: + sanitize_serialized_mount_credentials(payload) + + assert sentinel not in repr(exc_info.value) + + def test_docker_volume_mount_keeps_credentials_outside_sandbox() -> None: manifest = _s3_manifest(strategy=DockerVolumeMountStrategy(driver="rclone")) validate_mount_credential_boundaries(manifest) -def test_trusted_manifest_can_acknowledge_one_in_container_mount() -> None: +@pytest.mark.parametrize( + ("source", "option_name", "option_value"), + [ + ("mount", "password", "S3_FILES_OPTION_SECRET_SENTINEL"), + ("pattern", "token", "S3_FILES_OPTION_SECRET_SENTINEL"), + ("mount", "iam", None), + ("pattern", "auth", "S3_FILES_OPTION_SECRET_SENTINEL"), + ], +) +def test_s3_files_options_reject_arbitrary_values( + source: str, + option_name: str, + option_value: str | None, +) -> None: + sentinel = "S3_FILES_OPTION_SECRET_SENTINEL" + mount_options = {option_name: option_value} if source == "mount" else {} + pattern_options = {option_name: option_value} if source == "pattern" else {} + manifest = Manifest( + entries={ + "remote": S3FilesMount( + file_system_id="fs-123", + extra_options=mount_options, + mount_strategy=InContainerMountStrategy( + pattern=S3FilesMountPattern( + options=S3FilesMountPattern.S3FilesOptions(extra_options=pattern_options) + ) + ), + ) + } + ) + + with pytest.raises(MountConfigError) as typed_error: + validate_mount_credential_boundaries(manifest) + with pytest.raises(ValueError) as raw_error: + sanitize_serialized_mount_credentials({"manifest": manifest.model_dump(mode="json")}) + + assert sentinel not in repr(typed_error.value) + assert sentinel not in repr(raw_error.value) + + +def test_s3_files_typed_options_remain_supported() -> None: + manifest = Manifest( + entries={ + "remote": S3FilesMount( + file_system_id="fs-123", + mount_target_ip="10.0.0.10", + access_point="ap-123", + region="us-east-1", + mount_strategy=InContainerMountStrategy(pattern=S3FilesMountPattern()), + ) + } + ) + + validate_mount_credential_boundaries(manifest) + + +def test_in_container_mount_credentials_cannot_be_acknowledged() -> None: manifest = _s3_manifest(strategy=InContainerMountStrategy(pattern=RcloneMountPattern())) - trusted = manifest.with_in_container_mount_credential_exposure_allowed("remote") - validate_mount_credential_boundaries(trusted) + with pytest.raises(MountConfigError, match="cannot be passed to a helper"): + validate_mount_credential_boundaries(manifest) - with pytest.raises(MountConfigError): + +@pytest.mark.parametrize( + "mount", + [ + GCSMount( + bucket="example", + service_account_file="/trusted/credentials.json", + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ), + BoxMount( + box_config_file="/trusted/box.json", + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ), + S3Mount( + bucket="example", + mount_strategy=InContainerMountStrategy( + pattern=RcloneMountPattern(config_file_path=Path("/trusted/rclone.conf")) + ), + ), + ], +) +def test_in_container_mount_rejects_file_backed_credentials(mount: Any) -> None: + manifest = Manifest(entries={"remote": mount}) + + with pytest.raises(MountConfigError) as exc_info: validate_mount_credential_boundaries(manifest) - assert "in_container_mount_credential_exposure_allowed_paths" not in trusted.model_dump() + + assert "credentials.json" not in repr(exc_info.value) + assert "box.json" not in repr(exc_info.value) + assert "rclone.conf" not in repr(exc_info.value) + + +@pytest.mark.parametrize( + "mount", + [ + S3Mount( + bucket="example", + mount_strategy=InContainerMountStrategy(pattern=MountpointMountPattern()), + ), + AzureBlobMount( + account="example", + container="public", + mount_strategy=InContainerMountStrategy(pattern=FuseMountPattern()), + ), + ], +) +def test_credentialless_in_container_pattern_cannot_use_ambient_identity(mount: Any) -> None: + with pytest.raises(MountConfigError, match="requires ambient credentials"): + validate_mount_credential_boundaries(Manifest(entries={"remote": mount})) + + +def test_in_container_mount_rejects_ambient_identity_selector() -> None: + manifest = Manifest( + entries={ + "remote": AzureBlobMount( + account="example", + container="private", + identity_client_id="managed-identity-client", + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ) + } + ) + + with pytest.raises(MountConfigError, match="ambient cloud identity") as exc_info: + validate_mount_credential_boundaries(manifest) + + assert exc_info.value.context["credential_fields"] == ["identity_client_id"] + + +@pytest.mark.parametrize("state_field", ["base_envs", "base_env_vars", "secret_refs"]) +def test_serialized_provider_environment_rejected_with_in_container_mount( + state_field: str, +) -> None: + manifest = _s3_manifest( + strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + credentials=False, + ) + payload: dict[str, object] = { + "manifest": manifest.model_dump(mode="json"), + state_field: {"AWS_SECRET_ACCESS_KEY": "PROVIDER_ENV_SECRET_SENTINEL"}, + } + + with pytest.raises(ValueError, match="credential-like manifest environment") as exc_info: + sanitize_serialized_mount_credentials(payload) + + assert "PROVIDER_ENV_SECRET_SENTINEL" not in repr(exc_info.value) def test_manifest_dict_cannot_enable_credential_exposure() -> None: - with pytest.raises(TypeError, match="trusted Manifest instance"): + with pytest.raises(TypeError, match="not supported"): _coerce_manifest( {"in_container_mount_credential_exposure_allowed_paths": ["remote"]}, parameter_name="manifest", @@ -229,7 +1394,7 @@ def test_manifest_dict_cannot_enable_credential_exposure() -> None: ], ) def test_persisted_manifest_cannot_enable_credential_exposure(policy_key: str) -> None: - payload = UnixLocalSandboxSessionState( + payload = _MountSecuritySessionState( manifest=Manifest(), snapshot=NoopSnapshot(id="snapshot"), ).model_dump(mode="json") @@ -262,7 +1427,10 @@ async def apply_entry(*args: object) -> list[Any]: exec_checked_nonzero=exec_checked_nonzero, apply_entry=apply_entry, ) - manifest = _s3_manifest(strategy=InContainerMountStrategy(pattern=RcloneMountPattern())) + manifest = _s3_manifest( + strategy=InContainerMountStrategy(pattern=RcloneMountPattern(remote_name=":s3")), + credentials=False, + ) with pytest.raises(MountConfigError): await applier.apply_manifest(manifest) diff --git a/tests/sandbox/test_mounts.py b/tests/sandbox/test_mounts.py index 96cf316dcf..aa63d5560b 100644 --- a/tests/sandbox/test_mounts.py +++ b/tests/sandbox/test_mounts.py @@ -28,8 +28,10 @@ MountpointMountConfig, RcloneMountConfig, S3FilesMountConfig, + _redact_sensitive_values, ) from agents.sandbox.errors import MountCommandError, MountConfigError +from agents.sandbox.manifest import Environment, StrEnvValue from agents.sandbox.session.base_sandbox_session import BaseSandboxSession from agents.sandbox.session.events import SandboxSessionEvent from agents.sandbox.session.manager import Instrumentation @@ -139,7 +141,13 @@ async def hydrate_workspace(self, data: io.IOBase) -> None: class _GeneratedConfigApplySession(BaseSandboxSession): - def __init__(self, *, session_id: uuid.UUID) -> None: + def __init__( + self, + *, + session_id: uuid.UUID, + fail_command_contains: str | None = None, + fail_stderr: bytes = b"mount failed", + ) -> None: self.state = TestSessionState( session_id=session_id, manifest=Manifest(root="/workspace"), @@ -147,6 +155,8 @@ def __init__(self, *, session_id: uuid.UUID) -> None: ) self.exec_calls: list[list[str]] = [] self.write_calls: list[tuple[Path, bytes]] = [] + self._fail_command_contains = fail_command_contains + self._fail_stderr = fail_stderr async def read(self, path: Path, *, user: object = None) -> io.BytesIO: _ = (path, user) @@ -157,7 +167,8 @@ async def shutdown(self) -> None: async def write(self, path: Path, data: io.IOBase, *, user: object = None) -> None: _ = user - self.write_calls.append((path, data.read())) + payload = data.read() + self.write_calls.append((path, payload)) async def running(self) -> bool: return True @@ -168,7 +179,12 @@ async def _exec_internal( timeout: float | None = None, ) -> ExecResult: _ = timeout - self.exec_calls.append([str(part) for part in command]) + command_parts = [str(part) for part in command] + self.exec_calls.append(command_parts) + if self._fail_command_contains is not None and self._fail_command_contains in " ".join( + command_parts + ): + return ExecResult(exit_code=1, stdout=b"", stderr=self._fail_stderr) return ExecResult(exit_code=0, stdout=b"", stderr=b"") async def persist_workspace(self) -> io.IOBase: @@ -308,11 +324,83 @@ async def test_azure_blob_mount_builds_rclone_runtime_config_without_hidden_patt assert apply_config.remote_path == "container" assert apply_config.config_text is not None assert "account = acct" in apply_config.config_text + assert "use_msi = false" in apply_config.config_text + assert "use_msi = true" not in apply_config.config_text assert isinstance(unmount_config, RcloneMountConfig) assert unmount_config.remote_name == remote_name assert unmount_config.config_text is None +def test_manifest_persistence_excludes_all_configured_mount_credential_files() -> None: + manifest = Manifest( + entries={ + "rclone-remote": S3Mount( + bucket="example", + mount_strategy=InContainerMountStrategy( + pattern=RcloneMountPattern(config_file_path=Path("secrets/rclone.conf")) + ), + ), + "gcs-remote": GCSMount( + bucket="example", + service_account_file="secrets/gcs.json", + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ), + "docker-remote": GCSMount( + bucket="example", + mount_strategy=DockerVolumeMountStrategy( + driver="rclone", + driver_options={ + "gcs-service-account-file": "/workspace/secrets/docker-gcs.json" + }, + ), + ), + "docker-camel-remote": S3Mount( + bucket="example", + mount_strategy=DockerVolumeMountStrategy( + driver="rclone", + driver_options={"apiKeyFile": "secrets/docker-api-key.json"}, + ), + ), + "docker-compact-remote": S3Mount( + bucket="example", + mount_strategy=DockerVolumeMountStrategy( + driver="rclone", + driver_options={"apikeyfile": "secrets/docker-compact-key.json"}, + ), + ), + "docker-shared-credentials-remote": S3Mount( + bucket="example", + mount_strategy=DockerVolumeMountStrategy( + driver="rclone", + driver_options={"s3-shared-credentials-file": "secrets/s3-shared-credentials"}, + ), + ), + "blobfuse-remote": AzureBlobMount( + account="account", + container="container", + mount_strategy=InContainerMountStrategy(pattern=FuseMountPattern()), + ), + "mountpoint-remote": S3Mount( + bucket="example", + mount_strategy=InContainerMountStrategy(pattern=MountpointMountPattern()), + ), + } + ) + + assert { + Path("secrets/rclone.conf"), + Path("secrets/gcs.json"), + Path("secrets/docker-gcs.json"), + Path("secrets/docker-api-key.json"), + Path("secrets/docker-compact-key.json"), + Path("secrets/s3-shared-credentials"), + Path(".sandbox-rclone-config"), + Path(".sandbox-blobfuse-cache"), + Path(".sandbox-blobfuse-config"), + Path(".sandbox-mountpoint-env"), + } <= manifest.ephemeral_persistence_paths() + + @pytest.mark.asyncio async def test_box_mount_builds_rclone_runtime_config_with_box_auth_options() -> None: session_id = uuid.uuid4() @@ -662,6 +750,9 @@ async def test_gcs_mount_builds_native_rclone_config_with_service_account_auth() assert isinstance(config, RcloneMountConfig) assert config.remote_name == remote_name assert config.remote_path == "bucket/nested/prefix/" + assert config.config_text is not None + assert "env_auth = false" in config.config_text + assert "env_auth = true" not in config.config_text assert config.config_text == ( f"[{remote_name}]\n" "type = google cloud storage\n" @@ -672,6 +763,32 @@ async def test_gcs_mount_builds_native_rclone_config_with_service_account_auth() ) +@pytest.mark.asyncio +async def test_gcs_mount_builds_explicit_anonymous_rclone_config_without_credentials() -> None: + session_id = uuid.uuid4() + pattern = RcloneMountPattern() + remote_name = pattern.resolve_remote_name( + session_id=session_id.hex, + remote_kind="gcs", + mount_type="gcs_mount", + ) + mount = GCSMount( + bucket="public-bucket", + mount_strategy=InContainerMountStrategy(pattern=pattern), + ) + + config = await mount.build_in_container_mount_config( + _MountConfigSession(session_id=session_id), + pattern, + include_config_text=True, + ) + + assert isinstance(config, RcloneMountConfig) + assert config.config_text == ( + f"[{remote_name}]\ntype = google cloud storage\nenv_auth = false\nanonymous = true\n" + ) + + @pytest.mark.asyncio async def test_gcs_mount_builds_s3_compatible_rclone_config_with_hmac_auth() -> None: session_id = uuid.uuid4() @@ -798,6 +915,9 @@ async def test_s3_mount_builds_prefixed_rclone_remote_path() -> None: assert isinstance(config, RcloneMountConfig) assert config.remote_name == remote_name assert config.remote_path == "bucket/nested/prefix/" + assert config.config_text is not None + assert "env_auth = false" in config.config_text + assert "env_auth = true" not in config.config_text @pytest.mark.asyncio @@ -950,7 +1070,7 @@ async def test_r2_mount_builds_rclone_config_with_explicit_credentials() -> None @pytest.mark.asyncio -async def test_r2_mount_builds_env_auth_config_with_custom_domain() -> None: +async def test_r2_mount_builds_anonymous_config_with_custom_domain() -> None: session_id = uuid.uuid4() pattern = RcloneMountPattern() remote_name = pattern.resolve_remote_name( @@ -980,7 +1100,7 @@ async def test_r2_mount_builds_env_auth_config_with_custom_domain() -> None: "provider = Cloudflare\n" "endpoint = https://eu.r2.cloudflarestorage.com\n" "acl = private\n" - "env_auth = true\n" + "env_auth = false\n" ) @@ -1171,6 +1291,245 @@ async def test_rclone_generated_config_is_written_owner_only() -> None: ] +@pytest.mark.asyncio +async def test_rclone_generated_config_is_removed_after_apply_failure_and_unapply() -> None: + session_id = uuid.UUID("12345678-1234-5678-1234-567812345678") + config = RcloneMountConfig( + remote_name="remote", + remote_path="bucket", + remote_kind="s3", + mount_type="s3_mount", + config_text="[remote]\ntype = s3\nenv_auth = false\nno_check_bucket = true\n", + ) + failed_session = _GeneratedConfigApplySession( + session_id=session_id, + fail_command_contains="rclone mount", + ) + pattern = RcloneMountPattern() + + with pytest.raises(MountCommandError): + await pattern.apply(failed_session, Path("/workspace/mnt"), config) + + expected_config_path = ( + "/workspace/.sandbox-rclone-config/12345678123456781234567812345678/remote.conf" + ) + assert failed_session.exec_calls[-1] == ["rm", "-f", expected_config_path] + + cleanup_session = _GeneratedConfigApplySession(session_id=session_id) + await pattern.unapply( + cleanup_session, + Path("/workspace/mnt"), + RcloneMountConfig( + remote_name="remote", + remote_path="bucket", + remote_kind="s3", + mount_type="s3_mount", + ), + ) + + assert cleanup_session.exec_calls[-1] == ["rm", "-f", expected_config_path] + + +@pytest.mark.asyncio +async def test_rclone_mount_rejects_credentials_before_public_apply_side_effects() -> None: + sentinel = "RCLONE_PUBLIC_BOUNDARY_SECRET_SENTINEL" + mount = S3Mount( + bucket="bucket", + access_key_id="access", + secret_access_key=sentinel, + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ) + session = _GeneratedConfigApplySession( + session_id=uuid.UUID("12345678-1234-5678-1234-567812345678"), + fail_command_contains="rclone mount", + fail_stderr=sentinel.encode(), + ) + + with pytest.raises(MountConfigError) as exc_info: + await mount.apply(session, Path("/workspace/remote"), Path.cwd()) + + assert sentinel not in repr(exc_info.value) + assert session.exec_calls == [] + traceback = exc_info.value.__traceback__ + while traceback is not None: + if "/src/agents/" in Path(traceback.tb_frame.f_code.co_filename).as_posix(): + assert sentinel not in repr(traceback.tb_frame.f_locals) + traceback = traceback.tb_next + + +@pytest.mark.asyncio +async def test_rclone_mount_rejects_credentials_before_public_unmount_side_effects() -> None: + mount = S3Mount( + bucket="bucket", + access_key_id="access", + secret_access_key="secret", + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ) + session = _GeneratedConfigApplySession( + session_id=uuid.UUID("12345678-1234-5678-1234-567812345678"), + ) + + with pytest.raises(MountConfigError, match="cannot be passed to a helper inside"): + await mount.unmount(session, Path("/workspace/remote"), Path.cwd()) + + assert session.exec_calls == [] + + +@pytest.mark.parametrize( + "mount", + [ + pytest.param( + S3Mount( + bucket="bucket", + mount_strategy=InContainerMountStrategy(pattern=MountpointMountPattern()), + ), + id="implicit-mountpoint-identity", + ), + pytest.param( + AzureBlobMount( + account="account", + container="container", + identity_client_id="client-id", + mount_strategy=InContainerMountStrategy(pattern=FuseMountPattern()), + ), + id="ambient-identity-reference", + ), + pytest.param( + S3Mount( + bucket="bucket", + mount_strategy=InContainerMountStrategy( + pattern=RcloneMountPattern(extra_args=["--config", "/tmp/rclone.conf"]), + ), + ), + id="rclone-extra-args", + ), + pytest.param( + S3FilesMount( + file_system_id="fs-1234567890abcdef0", + mount_strategy=InContainerMountStrategy( + pattern=S3FilesMountPattern( + options=S3FilesMountPattern.S3FilesOptions( + extra_options={"auth": "enabled"}, + ) + ), + ), + ), + id="s3files-pattern-options", + ), + ], +) +@pytest.mark.asyncio +async def test_public_mount_apply_reuses_complete_manifest_credential_validation( + mount: Mount, +) -> None: + session = _GeneratedConfigApplySession( + session_id=uuid.UUID("12345678-1234-5678-1234-567812345678"), + ) + + with pytest.raises(MountConfigError): + await mount.apply(session, Path("/workspace/remote"), Path.cwd()) + + assert session.exec_calls == [] + assert session.write_calls == [] + + +@pytest.mark.asyncio +async def test_public_mount_apply_rejects_manifest_credential_environment() -> None: + mount = S3Mount( + bucket="bucket", + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ) + session = _GeneratedConfigApplySession( + session_id=uuid.UUID("12345678-1234-5678-1234-567812345678"), + ) + session.state.manifest.environment = Environment( + value={ + "AWS_SECRET_ACCESS_KEY": StrEnvValue(value="secret"), + } + ) + + with pytest.raises(MountConfigError, match="credential-like environment variables"): + await mount.apply(session, Path("/workspace/remote"), Path.cwd()) + + assert session.exec_calls == [] + assert session.write_calls == [] + + +@pytest.mark.asyncio +async def test_trusted_rclone_config_file_is_rejected_before_read() -> None: + read_called = False + + class _FailingConfigReadSession(_GeneratedConfigApplySession): + async def read(self, path: Path, *, user: object = None) -> io.BytesIO: + nonlocal read_called + read_called = True + _ = (path, user) + raise RuntimeError("config read failed") + + session = _FailingConfigReadSession( + session_id=uuid.UUID("12345678-1234-5678-1234-567812345678") + ) + session.state.manifest = Manifest( + entries={ + "remote": S3Mount( + bucket="bucket", + mount_strategy=InContainerMountStrategy( + pattern=RcloneMountPattern(config_file_path=Path("rclone.conf")) + ), + ) + } + ) + + with pytest.raises(MountConfigError, match="cannot be passed to a helper"): + await session.apply_manifest() + + assert read_called is False + + +@pytest.mark.asyncio +async def test_rclone_nfs_failures_omit_provider_stderr() -> None: + session_id = uuid.UUID("12345678-1234-5678-1234-567812345678") + sentinel = "RCLONE_NFS_FAILURE_SECRET_SENTINEL" + config = RcloneMountConfig( + remote_name="remote", + remote_path="bucket", + remote_kind="s3", + mount_type="s3_mount", + config_text=f"[remote]\ntype = s3\nsecret = {sentinel}\n", + ) + config_path = Path("/workspace/.sandbox-rclone-config/session/remote.conf") + + server_session = _GeneratedConfigApplySession( + session_id=session_id, + fail_command_contains="rclone serve nfs remote:bucket", + fail_stderr=sentinel.encode(), + ) + pattern = RcloneMountPattern(mode="nfs") + with pytest.raises(MountCommandError) as server_error: + await pattern._start_rclone_server( + server_session, + config=config, + config_path=config_path, + nfs_addr="127.0.0.1:2049", + ) + assert sentinel not in repr(server_error.value) + + client_session = _GeneratedConfigApplySession( + session_id=session_id, + fail_command_contains="mount -v -t nfs", + fail_stderr=sentinel.encode(), + ) + with pytest.raises(MountCommandError) as client_error: + await pattern._start_rclone_client( + client_session, + path=Path("/workspace/mnt"), + config=config, + config_path=config_path, + nfs_addr="127.0.0.1:2049", + ) + assert sentinel not in repr(client_error.value) + + @pytest.mark.asyncio async def test_blobfuse_generated_config_is_written_owner_only() -> None: session_id = uuid.UUID("12345678-1234-5678-1234-567812345678") @@ -1253,6 +1612,71 @@ async def test_blobfuse_generated_config_is_written_owner_only() -> None: ] +@pytest.mark.asyncio +async def test_blobfuse_failure_redacts_account_key_and_removes_config() -> None: + session_id = uuid.UUID("12345678-1234-5678-1234-567812345678") + sentinel = "BLOBFUSE_ACCOUNT_KEY_SECRET_SENTINEL" + session = _GeneratedConfigApplySession( + session_id=session_id, + fail_command_contains="blobfuse2 mount", + fail_stderr=sentinel.encode(), + ) + + with pytest.raises(MountCommandError) as exc_info: + await FuseMountPattern().apply( + session, + Path("/workspace/mnt"), + FuseMountConfig( + account="acct", + container="container", + endpoint=None, + identity_client_id=None, + account_key=sentinel, + mount_type="azure_blob_mount", + ), + ) + + assert sentinel not in repr(exc_info.value) + traceback = exc_info.value.__traceback__ + while traceback is not None: + if "/src/agents/" in Path(traceback.tb_frame.f_code.co_filename).as_posix(): + assert sentinel not in repr(traceback.tb_frame.f_locals) + traceback = traceback.tb_next + assert session.exec_calls[-1] == [ + "rm", + "-f", + "/workspace/.sandbox-blobfuse-config/12345678123456781234567812345678/acct_container.yaml", + ] + + +@pytest.mark.asyncio +async def test_blobfuse_generated_config_is_removed_on_unapply() -> None: + session_id = uuid.UUID("12345678-1234-5678-1234-567812345678") + + blobfuse_session = _GeneratedConfigApplySession(session_id=session_id) + await FuseMountPattern().unapply( + blobfuse_session, + Path("/workspace/blob"), + FuseMountConfig( + account="acct", + container="container", + endpoint=None, + identity_client_id=None, + account_key=None, + mount_type="azure_blob_mount", + ), + ) + assert blobfuse_session.exec_calls[-1] == [ + "rm", + "-f", + "/workspace/.sandbox-blobfuse-config/12345678123456781234567812345678/acct_container.yaml", + ] + + +def test_mount_error_redaction_handles_overlapping_credential_values() -> None: + assert _redact_sensitive_values("abcdef", ["abc", "abcdef"]) == "REDACTED" + + @pytest.mark.asyncio async def test_blobfuse_generated_config_preserves_zero_attr_cache_timeout() -> None: session_id = uuid.UUID("12345678-1234-5678-1234-567812345678") diff --git a/tests/sandbox/test_runtime.py b/tests/sandbox/test_runtime.py index 9aaa48308e..27e0fa0a24 100644 --- a/tests/sandbox/test_runtime.py +++ b/tests/sandbox/test_runtime.py @@ -64,6 +64,7 @@ ExecNonZeroError, ExecTransportError, InvalidManifestPathError, + MountConfigError, WorkspaceArchiveWriteError, ) from agents.sandbox.files import EntryKind, FileEntry @@ -103,6 +104,12 @@ from tests.utils.simple_session import SimpleListSession +class _CredentialEnvironmentTestState(TestSessionState): + __test__ = False + type: Literal["credential-environment-test"] = "credential-environment-test" + base_envs: dict[str, str] + + class _FakeSession(BaseSandboxSession): def __init__( self, @@ -116,6 +123,7 @@ def __init__( ) self._start_gate = start_gate self._running = False + self.running_calls = 0 self.start_calls = 0 self.stop_calls = 0 self.shutdown_calls = 0 @@ -145,6 +153,7 @@ async def shutdown(self) -> None: self.shutdown_calls += 1 async def running(self) -> bool: + self.running_calls += 1 return self._running async def read(self, path: Path, *, user: object = None) -> io.BytesIO: @@ -1590,6 +1599,178 @@ async def test_runner_does_not_restart_running_injected_sandbox_session() -> Non assert injected_session.shutdown_calls == 0 +@pytest.mark.parametrize("running", [False, True]) +@pytest.mark.asyncio +async def test_session_manager_rejects_injected_credentialed_in_container_mount( + running: bool, +) -> None: + sentinel = "INJECTED_SESSION_SECRET_SENTINEL" + live_session = _FakeSession( + Manifest( + entries={ + "remote": S3Mount( + bucket="example", + access_key_id="access-key", + secret_access_key=sentinel, + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ) + } + ) + ) + live_session._running = running + agent = SandboxAgent(name="worker", model=FakeModel(), instructions="Worker.") + manager = SandboxRuntimeSessionManager( + starting_agent=agent, + sandbox_config=SandboxRunConfig(session=live_session), + run_state=None, + ) + manager.acquire_agent(agent) + + with pytest.raises(MountConfigError) as exc_info: + await manager.ensure_session( + agent=agent, + capabilities=[], + is_resumed_state=False, + ) + + assert live_session.start_calls == 0 + assert live_session.running_calls == 0 + assert sentinel not in repr(exc_info.value) + traceback = exc_info.value.__traceback__ + while traceback is not None: + if "/src/agents/" in Path(traceback.tb_frame.f_code.co_filename).as_posix(): + assert sentinel not in repr(traceback.tb_frame.f_locals) + traceback = traceback.tb_next + + +@pytest.mark.asyncio +async def test_session_manager_rejects_capability_mount_before_injected_session_probe() -> None: + sentinel = "INJECTED_CAPABILITY_SECRET_SENTINEL" + live_session = _FakeSession(Manifest()) + capability = _ManifestMountCapability( + S3Mount( + bucket="example", + access_key_id="access-key", + secret_access_key=sentinel, + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ) + ) + agent = SandboxAgent(name="worker", model=FakeModel(), instructions="Worker.") + manager = SandboxRuntimeSessionManager( + starting_agent=agent, + sandbox_config=SandboxRunConfig(session=live_session), + run_state=None, + ) + manager.acquire_agent(agent) + + with pytest.raises(MountConfigError) as exc_info: + await manager.ensure_session( + agent=agent, + capabilities=[capability], + is_resumed_state=False, + ) + + assert capability.process_calls == 1 + assert live_session.running_calls == 0 + assert live_session.start_calls == 0 + assert sentinel not in repr(exc_info.value) + + +@pytest.mark.asyncio +async def test_session_manager_rejects_injected_credential_environment_before_probe() -> None: + sentinel = "INJECTED_ENVIRONMENT_SECRET_SENTINEL" + manifest = Manifest( + entries={ + "remote": S3Mount( + bucket="example", + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ) + } + ) + live_session = _FakeSession(manifest) + live_session.state = _CredentialEnvironmentTestState( + manifest=manifest, + snapshot=NoopSnapshot(id="snapshot"), + base_envs={"AWS_SECRET_ACCESS_KEY": sentinel}, + ) + agent = SandboxAgent(name="worker", model=FakeModel(), instructions="Worker.") + manager = SandboxRuntimeSessionManager( + starting_agent=agent, + sandbox_config=SandboxRunConfig(session=live_session), + run_state=None, + ) + manager.acquire_agent(agent) + + with pytest.raises(MountConfigError) as exc_info: + await manager.ensure_session( + agent=agent, + capabilities=[], + is_resumed_state=False, + ) + + assert live_session.running_calls == 0 + assert live_session.start_calls == 0 + assert sentinel not in repr(exc_info.value) + + +@pytest.mark.asyncio +async def test_session_manager_rejects_credentialed_running_injected_mount() -> None: + manifest = Manifest( + entries={ + "remote": S3Mount( + bucket="example", + access_key_id="access-key", + secret_access_key="secret-key", + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ) + } + ) + live_session = _FakeSession(manifest) + live_session._running = True + agent = SandboxAgent(name="worker", model=FakeModel(), instructions="Worker.") + manager = SandboxRuntimeSessionManager( + starting_agent=agent, + sandbox_config=SandboxRunConfig(session=live_session), + run_state=None, + ) + manager.acquire_agent(agent) + + with pytest.raises(MountConfigError, match="cannot be passed to a helper"): + await manager.ensure_session( + agent=agent, + capabilities=[], + is_resumed_state=False, + ) + + assert live_session.running_calls == 0 + assert live_session.start_calls == 0 + + +@pytest.mark.asyncio +async def test_unix_client_rejection_clears_credential_traceback_frames() -> None: + sentinel = "DEFAULT_REJECTION_SECRET_SENTINEL" + manifest = Manifest( + entries={ + "remote": S3Mount( + bucket="example", + access_key_id="access-key", + secret_access_key=sentinel, + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ) + } + ) + + with pytest.raises(MountConfigError) as exc_info: + await UnixLocalSandboxClient().create(manifest=manifest, options=None) + + assert sentinel not in repr(exc_info.value) + traceback = exc_info.value.__traceback__ + while traceback is not None: + if "/src/agents/" in Path(traceback.tb_frame.f_code.co_filename).as_posix(): + assert sentinel not in repr(traceback.tb_frame.f_locals) + traceback = traceback.tb_next + + @pytest.mark.asyncio async def test_runner_guardrail_trip_blocks_runner_owned_sandbox_creation() -> None: session = _FakeSession(Manifest()) @@ -3423,10 +3604,10 @@ def test_session_manager_rebinds_mount_credentials_from_current_trusted_manifest bucket="example", access_key_id="access-key-sentinel", secret_access_key="secret-key-sentinel", - mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), ) } - ).with_in_container_mount_credential_exposure_allowed("remote") + ) client = _FakeClient(_FakeSession(Manifest())) payload = client.serialize_session_state( TestSessionState( @@ -3452,6 +3633,76 @@ def test_session_manager_rebinds_mount_credentials_from_current_trusted_manifest processed.assert_trusted_manifest_rebound() +def test_session_manager_preserves_persisted_non_mount_entries_during_credential_rebind() -> None: + persisted_manifest = Manifest( + entries={ + "note.txt": File(content=b"persisted"), + "remote": S3Mount( + bucket="example", + access_key_id="access-key-sentinel", + secret_access_key="secret-key-sentinel", + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ), + } + ) + trusted_manifest = persisted_manifest.model_copy(deep=True) + trusted_manifest.entries["note.txt"] = File(content=b"trusted") + client = _FakeClient(_FakeSession(Manifest())) + payload = client.serialize_session_state( + TestSessionState( + manifest=persisted_manifest, + snapshot=NoopSnapshot(id="resume"), + ) + ) + state = client._deserialize_session_state_payload(payload, TestSessionState) + agent = SandboxAgent(name="worker", model=FakeModel(), instructions="Worker.") + + processed = SandboxRuntimeSessionManager._process_resumed_state_manifest( + agent=agent, + capabilities=[], + session_state=state, + trusted_manifest=trusted_manifest, + ) + + note = processed.manifest.entries["note.txt"] + assert isinstance(note, File) + assert note.content == b"persisted" + processed.assert_trusted_manifest_rebound() + + +def test_session_manager_rejects_persisted_root_before_mount_credential_rebind() -> None: + trusted_manifest = Manifest( + root="/workspace", + entries={ + "remote": S3Mount( + bucket="example", + access_key_id="access-key-sentinel", + secret_access_key="secret-key-sentinel", + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ) + }, + ) + client = _FakeClient(_FakeSession(Manifest())) + payload = client.serialize_session_state( + TestSessionState( + manifest=trusted_manifest, + snapshot=NoopSnapshot(id="resume"), + ) + ) + manifest_payload = cast(dict[str, object], payload["manifest"]) + manifest_payload["root"] = "/tampered-root" + state = client._deserialize_session_state_payload(payload, TestSessionState) + agent = SandboxAgent(name="worker", model=FakeModel(), instructions="Worker.") + + with pytest.raises(ValueError, match="manifest root does not match"): + SandboxRuntimeSessionManager._process_resumed_state_manifest( + agent=agent, + capabilities=[], + session_state=state, + trusted_manifest=trusted_manifest, + ) + + def test_session_manager_rebinds_capability_added_mount_from_current_configuration() -> None: base_manifest = Manifest() capability = _ManifestMountCapability( diff --git a/tests/sandbox/test_session_state_roundtrip.py b/tests/sandbox/test_session_state_roundtrip.py index 8e09af9f1d..a0d5cc01f2 100644 --- a/tests/sandbox/test_session_state_roundtrip.py +++ b/tests/sandbox/test_session_state_roundtrip.py @@ -17,7 +17,17 @@ from pydantic import ConfigDict, ValidationError, field_serializer, field_validator from agents.sandbox import Manifest, SandboxPathGrant -from agents.sandbox.entries import File, InContainerMountStrategy, RcloneMountPattern, S3Mount +from agents.sandbox.entries import ( + AzureBlobMount, + BoxMount, + DockerVolumeMountStrategy, + File, + GCSMount, + InContainerMountStrategy, + RcloneMountPattern, + S3Mount, +) +from agents.sandbox.entries.base import BaseEntry from agents.sandbox.errors import MountConfigError from agents.sandbox.manifest import EnvEntry, Environment, EnvValue, StrEnvValue from agents.sandbox.session import ( @@ -54,6 +64,13 @@ class _SimpleSessionState(SandboxSessionState): type: Literal["simple-roundtrip"] = "simple-roundtrip" +class _DeclaredCredentialDockerStrategy(DockerVolumeMountStrategy): + __test__ = False + type: Literal["test_roundtrip_credential_docker"] = "test_roundtrip_credential_docker" # type: ignore[assignment] + api_key: str | None = None + _credential_field_names: ClassVar[frozenset[str]] = frozenset({"api_key"}) + + class _SecretReferenceEnvValue(EnvValue): __test__ = False type: Literal["test.session-secret-reference"] = "test.session-secret-reference" @@ -409,10 +426,10 @@ def test_client_serialization_redacts_mount_credentials_and_rebinds_from_trusted access_key_id="access-key-sentinel", secret_access_key="secret-key-sentinel", session_token="session-token-sentinel", - mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), ) } - ).with_in_container_mount_credential_exposure_allowed("remote") + ) state = _SimpleSessionState( manifest=trusted_manifest, snapshot=NoopSnapshot(id="snapshot"), @@ -424,7 +441,9 @@ def test_client_serialization_redacts_mount_credentials_and_rebinds_from_trusted assert "access-key-sentinel" not in encoded assert "secret-key-sentinel" not in encoded assert "session-token-sentinel" not in encoded - assert payload["__openai_agents_redacted_mount_credential_paths"] == ["remote"] + assert payload["__openai_agents_redacted_mount_credential_paths"] == { + "remote": ["access_key_id", "secret_access_key", "session_token"] + } restored = client.deserialize_session_state(payload) restored_mount = restored.manifest.entries["remote"] @@ -441,6 +460,373 @@ def test_client_serialization_redacts_mount_credentials_and_rebinds_from_trusted assert rebound.mount_credentials_require_rebind == () rebound.assert_trusted_manifest_rebound() + def test_direct_state_serialization_redacts_mount_credentials_and_preserves_rebind_marker( + self, + ) -> None: + trusted_manifest = Manifest( + entries={ + "remote": S3Mount( + bucket="example", + access_key_id="direct-access-key-sentinel", + secret_access_key="direct-secret-key-sentinel", + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ) + } + ) + state = _SimpleSessionState( + manifest=trusted_manifest, + snapshot=NoopSnapshot(id="snapshot"), + ) + + payload = cast(dict[str, object], state.model_dump(mode="json")) + encoded = state.model_dump_json() + + assert "direct-access-key-sentinel" not in json.dumps(payload) + assert "direct-secret-key-sentinel" not in json.dumps(payload) + assert "direct-access-key-sentinel" not in encoded + assert "direct-secret-key-sentinel" not in encoded + expected_marker = {"remote": ["access_key_id", "secret_access_key"]} + assert payload["__openai_agents_redacted_mount_credential_paths"] == expected_marker + assert json.loads(encoded)["__openai_agents_redacted_mount_credential_paths"] == ( + expected_marker + ) + + restored = SandboxSessionState.parse(payload) + assert restored.mount_credentials_require_rebind == ("remote",) + rebound = restored.rebind_persisted_mount_credentials(trusted_manifest) + rebound_mount = rebound.manifest.entries["remote"] + assert isinstance(rebound_mount, S3Mount) + assert rebound_mount.secret_access_key == "direct-secret-key-sentinel" + + def test_client_serialization_redacts_docker_driver_credentials_and_rebinds_them( + self, + ) -> None: + client = _RoundTripClient() + trusted_manifest = Manifest( + entries={ + "remote": S3Mount( + bucket="example", + mount_strategy=DockerVolumeMountStrategy( + driver="rclone", + driver_options={ + "s3-secret-access-key": "driver-secret-sentinel", + "api_key": "driver-api-key-sentinel", + "apiKey": "driver-camel-api-key-sentinel", + "apiKeyFile": "driver-api-key-file-sentinel", + "accessKey": "driver-access-key-sentinel", + "secretKey": "driver-secret-key-sentinel", + "api.key": "driver-dot-api-key-sentinel", + "secret.access.key": "driver-dot-secret-key-sentinel", + "password.file": "external-password-file.json", + "token.path": "external-token-path.json", + "s3-profile": "driver-profile-sentinel", + "s3-shared-credentials-file": "external-s3-credentials", + "s3-sse-customer-key": "driver-sse-key-sentinel", + "s3-sse-customer-key-base64": "driver-sse-base64-sentinel", + "sftp-ssh": "sshpass -p driver-sftp-ssh-sentinel ssh", + "http-proxy": ( + "https://user:driver-proxy-password-sentinel@proxy.example" + ), + "remote": "driver-remote-sentinel:bucket", + "fs": "driver-fs-sentinel:bucket", + "azureblob-key": "cross-mount-azure-key-sentinel", + "gcs-service-account-file": "external-gcs-config.json", + "secretary-mode": "strict", + "tokenizer-cache": "enabled", + "vfs-cache-mode": "off", + }, + ), + ) + } + ) + state = _SimpleSessionState( + manifest=trusted_manifest, + snapshot=NoopSnapshot(id="snapshot"), + ) + + payload = client.serialize_session_state(state) + encoded = json.dumps(payload) + + assert "driver-secret-sentinel" not in encoded + assert "driver-api-key-sentinel" not in encoded + assert "driver-camel-api-key-sentinel" not in encoded + assert "driver-api-key-file-sentinel" not in encoded + assert "driver-access-key-sentinel" not in encoded + assert "driver-secret-key-sentinel" not in encoded + assert "driver-dot-api-key-sentinel" not in encoded + assert "driver-dot-secret-key-sentinel" not in encoded + assert "external-password-file.json" not in encoded + assert "external-token-path.json" not in encoded + assert "driver-profile-sentinel" not in encoded + assert "external-s3-credentials" not in encoded + assert "driver-sse-key-sentinel" not in encoded + assert "driver-sse-base64-sentinel" not in encoded + assert "driver-sftp-ssh-sentinel" not in encoded + assert "driver-proxy-password-sentinel" not in encoded + assert "driver-remote-sentinel" not in encoded + assert "driver-fs-sentinel" not in encoded + assert "cross-mount-azure-key-sentinel" not in encoded + assert "external-gcs-config.json" not in encoded + assert "secretary-mode" in encoded + assert "tokenizer-cache" in encoded + assert "vfs-cache-mode" in encoded + assert payload["__openai_agents_redacted_mount_credential_paths"] == { + "remote": ["mount_strategy.driver_options"] + } + + restored = client.deserialize_session_state(payload) + restored_mount = restored.manifest.entries["remote"] + assert isinstance(restored_mount, S3Mount) + assert isinstance(restored_mount.mount_strategy, DockerVolumeMountStrategy) + assert restored_mount.mount_strategy.driver_options == { + "secretary-mode": "strict", + "tokenizer-cache": "enabled", + "vfs-cache-mode": "off", + } + + rebound = restored.rebind_persisted_mount_credentials(trusted_manifest) + rebound_mount = rebound.manifest.entries["remote"] + assert isinstance(rebound_mount, S3Mount) + assert isinstance(rebound_mount.mount_strategy, DockerVolumeMountStrategy) + assert rebound_mount.mount_strategy.driver_options == { + "s3-secret-access-key": "driver-secret-sentinel", + "api_key": "driver-api-key-sentinel", + "apiKey": "driver-camel-api-key-sentinel", + "apiKeyFile": "driver-api-key-file-sentinel", + "accessKey": "driver-access-key-sentinel", + "secretKey": "driver-secret-key-sentinel", + "api.key": "driver-dot-api-key-sentinel", + "secret.access.key": "driver-dot-secret-key-sentinel", + "password.file": "external-password-file.json", + "token.path": "external-token-path.json", + "s3-profile": "driver-profile-sentinel", + "s3-shared-credentials-file": "external-s3-credentials", + "s3-sse-customer-key": "driver-sse-key-sentinel", + "s3-sse-customer-key-base64": "driver-sse-base64-sentinel", + "sftp-ssh": "sshpass -p driver-sftp-ssh-sentinel ssh", + "http-proxy": "https://user:driver-proxy-password-sentinel@proxy.example", + "remote": "driver-remote-sentinel:bucket", + "fs": "driver-fs-sentinel:bucket", + "azureblob-key": "cross-mount-azure-key-sentinel", + "gcs-service-account-file": "external-gcs-config.json", + "secretary-mode": "strict", + "tokenizer-cache": "enabled", + "vfs-cache-mode": "off", + } + + def test_direct_state_serialization_redacts_parameterized_rclone_connection_authority( + self, + ) -> None: + sentinel = "DIRECT_RCLONE_CONNECTION_SECRET_SENTINEL" + state = _SimpleSessionState( + manifest=Manifest( + entries={ + "remote": S3Mount( + bucket="example", + mount_strategy=DockerVolumeMountStrategy( + driver="rclone", + driver_options={ + "remote": ( + f":s3,access_key_id=inline,secret_access_key={sentinel}:bucket" + ), + "vfs-cache-mode": "off", + }, + ), + ) + } + ), + snapshot=NoopSnapshot(id="snapshot"), + ) + + payload = cast(dict[str, object], state.model_dump(mode="json")) + encoded = state.model_dump_json() + + assert sentinel not in json.dumps(payload) + assert sentinel not in encoded + assert payload["__openai_agents_redacted_mount_credential_paths"] == { + "remote": ["mount_strategy.driver_options"] + } + serialized_manifest = cast(dict[str, object], payload["manifest"]) + serialized_entries = cast(dict[str, object], serialized_manifest["entries"]) + serialized_mount = cast(dict[str, object], serialized_entries["remote"]) + serialized_strategy = cast(dict[str, object], serialized_mount["mount_strategy"]) + assert serialized_strategy["driver_options"] == {"vfs-cache-mode": "off"} + + def test_registered_strategy_credentials_are_redacted_and_rebound(self) -> None: + sentinel = "REGISTERED_STRATEGY_CREDENTIAL_SENTINEL" + trusted_manifest = Manifest( + entries={ + "remote": S3Mount( + bucket="example", + mount_strategy=_DeclaredCredentialDockerStrategy( + driver="rclone", + api_key=sentinel, + ), + ) + } + ) + state = _SimpleSessionState( + manifest=trusted_manifest, + snapshot=NoopSnapshot(id="snapshot"), + ) + + payload = cast(dict[str, object], state.model_dump(mode="json")) + encoded = state.model_dump_json() + + assert sentinel not in json.dumps(payload) + assert sentinel not in encoded + assert payload["__openai_agents_redacted_mount_credential_paths"] == { + "remote": ["mount_strategy.credential"] + } + + restored = SandboxSessionState.parse(payload) + assert restored.mount_credentials_require_rebind == ("remote",) + rebound = restored.rebind_persisted_mount_credentials(trusted_manifest) + rebound_mount = rebound.manifest.entries["remote"] + assert isinstance(rebound_mount, S3Mount) + assert isinstance(rebound_mount.mount_strategy, _DeclaredCredentialDockerStrategy) + assert rebound_mount.mount_strategy.api_key == sentinel + + def test_mount_credential_rebind_requires_the_same_credential_identity(self) -> None: + client = _RoundTripClient() + manifest = Manifest( + entries={ + "remote": GCSMount( + bucket="example", + service_account_credentials='{"type":"service_account"}', + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ) + } + ) + state = _SimpleSessionState(manifest=manifest, snapshot=NoopSnapshot(id="snapshot")) + restored = client.deserialize_session_state(client.serialize_session_state(state)) + trusted_without_source = Manifest( + entries={ + "remote": GCSMount( + bucket="example", + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ) + } + ) + + with pytest.raises(ValueError, match="do not match current trusted configuration"): + restored.rebind_persisted_mount_credentials(trusted_without_source) + + def test_mount_credential_rebind_rejects_additional_current_identity(self) -> None: + client = _RoundTripClient() + manifest = Manifest( + entries={ + "remote": S3Mount( + bucket="example", + access_key_id="access", + secret_access_key="secret", + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ) + } + ) + restored = client.deserialize_session_state( + client.serialize_session_state( + _SimpleSessionState(manifest=manifest, snapshot=NoopSnapshot(id="snapshot")) + ) + ) + trusted_with_session_token = Manifest( + entries={ + "remote": S3Mount( + bucket="example", + access_key_id="access", + secret_access_key="secret", + session_token="token", + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ) + } + ) + + with pytest.raises(ValueError, match="do not match current trusted configuration"): + restored.rebind_persisted_mount_credentials(trusted_with_session_token) + + @pytest.mark.parametrize( + "option_name", + ["apiKeyFile", "apikeyfile", "password.file", "token.path"], + ) + def test_direct_state_serialization_rejects_driver_credential_file( + self, + option_name: str, + ) -> None: + sentinel = "DRIVER_CREDENTIAL_FILE_SECRET_SENTINEL" + state = _SimpleSessionState( + manifest=Manifest( + entries={ + "credentials.json": File(content=sentinel.encode()), + "remote": S3Mount( + bucket="example", + mount_strategy=DockerVolumeMountStrategy( + driver="rclone", + driver_options={option_name: "credentials.json"}, + ), + ), + } + ), + snapshot=NoopSnapshot(id="snapshot"), + ) + + with pytest.raises(ValueError, match="must not be a manifest entry") as exc_info: + state.model_dump(mode="json") + + assert sentinel not in repr(exc_info.value) + + def test_client_serialization_redacts_azure_driver_credentials(self) -> None: + client = _RoundTripClient() + credentials = { + "azureblob-client-certificate-password": "certificate-password-sentinel", + "azureblob-client-certificate-path": "external-certificate.pem", + "azureblob-client-secret": "client-secret-sentinel", + "azureblob-connection-string": "connection-string-sentinel", + "azureblob-key": "azure-driver-key-sentinel", + "azureblob-password": "password-sentinel", + "azureblob-sas-url": "sas-url-sentinel", + "azureblob-service-principal-file": "external-principal.json", + } + trusted_manifest = Manifest( + entries={ + "remote": AzureBlobMount( + account="account", + container="container", + mount_strategy=DockerVolumeMountStrategy( + driver="rclone", + driver_options={ + **credentials, + "vfs-cache-mode": "off", + }, + ), + ) + } + ) + state = _SimpleSessionState( + manifest=trusted_manifest, + snapshot=NoopSnapshot(id="snapshot"), + ) + + payload = client.serialize_session_state(state) + + encoded = json.dumps(payload) + assert all(value not in encoded for value in credentials.values()) + assert payload["__openai_agents_redacted_mount_credential_paths"] == { + "remote": ["mount_strategy.driver_options"] + } + restored = client.deserialize_session_state(payload) + restored_mount = restored.manifest.entries["remote"] + assert isinstance(restored_mount, AzureBlobMount) + assert isinstance(restored_mount.mount_strategy, DockerVolumeMountStrategy) + assert restored_mount.mount_strategy.driver_options == {"vfs-cache-mode": "off"} + rebound = restored.rebind_persisted_mount_credentials(trusted_manifest) + rebound_mount = rebound.manifest.entries["remote"] + assert isinstance(rebound_mount, AzureBlobMount) + assert isinstance(rebound_mount.mount_strategy, DockerVolumeMountStrategy) + assert rebound_mount.mount_strategy.driver_options == { + **credentials, + "vfs-cache-mode": "off", + } + @pytest.mark.asyncio async def test_direct_resume_requires_mount_credential_rebind(self) -> None: client = _RoundTripClient() @@ -450,10 +836,10 @@ async def test_direct_resume_requires_mount_credential_rebind(self) -> None: bucket="example", access_key_id="access-key", secret_access_key="secret-key", - mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), ) } - ).with_in_container_mount_credential_exposure_allowed("remote") + ) state = _SimpleSessionState( manifest=trusted_manifest, snapshot=NoopSnapshot(id="snapshot"), @@ -481,7 +867,8 @@ def test_unmarked_serialized_mount_credentials_are_discarded(self) -> None: manifest=trusted_manifest, snapshot=NoopSnapshot(id="snapshot"), ) - payload = cast(dict[str, object], state.model_dump(mode="json")) + payload = cast(dict[str, object], state.model_dump(mode="json", exclude={"manifest"})) + payload["manifest"] = state.manifest.model_dump(mode="json") restored = client.deserialize_session_state(payload) @@ -528,7 +915,8 @@ def test_raw_mount_credentials_are_removed_before_validation(self) -> None: manifest=trusted_manifest, snapshot=NoopSnapshot(id="snapshot"), ) - payload = cast(dict[str, object], state.model_dump(mode="json")) + payload = cast(dict[str, object], state.model_dump(mode="json", exclude={"manifest"})) + payload["manifest"] = state.manifest.model_dump(mode="json") manifest_payload = cast(dict[str, object], payload["manifest"]) entries_payload = cast(dict[str, object], manifest_payload["entries"]) mount_payload = cast(dict[str, object], entries_payload["remote"]) @@ -543,16 +931,19 @@ def test_raw_mount_credentials_are_removed_before_validation(self) -> None: assert sentinel not in parsed.model_dump_json() assert parsed.mount_credentials_require_rebind == ("remote",) - @pytest.mark.parametrize("entry_type", [None, "unknown_mount"]) + @pytest.mark.parametrize( + ("entry_type", "include_mount_strategy"), + [(None, False), (None, True), ("unknown_mount", False), ("unknown_mount", True)], + ) def test_malformed_entry_credentials_are_removed_before_validation( self, entry_type: str | None, + include_mount_strategy: bool, ) -> None: sentinel = "MALFORMED_ENTRY_SECRET_SENTINEL" - raw_entry: dict[str, object] = { - "mount_strategy": "invalid", - "client_secret": sentinel, - } + raw_entry: dict[str, object] = {"client_secret": sentinel} + if include_mount_strategy: + raw_entry["mount_strategy"] = "invalid" if entry_type is not None: raw_entry["type"] = entry_type payload: dict[str, object] = { @@ -564,7 +955,14 @@ def test_malformed_entry_credentials_are_removed_before_validation( with pytest.raises((TypeError, ValueError, ValidationError)) as exc_info: SandboxSessionState.parse(payload) + assert sentinel not in str(exc_info.value) assert sentinel not in repr(exc_info.value) + assert sentinel not in repr(exc_info.value.args) + traceback = exc_info.value.__traceback__ + while traceback is not None: + if "/src/agents/" in Path(traceback.tb_frame.f_code.co_filename).as_posix(): + assert sentinel not in repr(traceback.tb_frame.f_locals) + traceback = traceback.tb_next def test_malformed_rclone_pattern_source_is_removed_before_validation(self) -> None: client = _RoundTripClient() @@ -603,10 +1001,10 @@ def test_rebind_rejects_persisted_mount_destination_changes(self) -> None: endpoint_url="https://trusted.example.com", access_key_id="trusted-access", secret_access_key="trusted-secret", - mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), ) } - ).with_in_container_mount_credential_exposure_allowed("remote") + ) state = _SimpleSessionState( manifest=trusted_manifest, snapshot=NoopSnapshot(id="snapshot"), @@ -625,6 +1023,34 @@ def test_rebind_rejects_persisted_mount_destination_changes(self) -> None: assert "trusted-access" not in restored.model_dump_json() assert "trusted-secret" not in restored.model_dump_json() + def test_rebind_rejects_persisted_manifest_root_changes(self) -> None: + client = _RoundTripClient() + trusted_manifest = Manifest( + root="/workspace", + entries={ + "remote": S3Mount( + bucket="trusted-bucket", + access_key_id="trusted-access", + secret_access_key="trusted-secret", + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ) + }, + ) + state = _SimpleSessionState( + manifest=trusted_manifest, + snapshot=NoopSnapshot(id="snapshot"), + ) + payload = client.serialize_session_state(state) + manifest_payload = cast(dict[str, object], payload["manifest"]) + manifest_payload["root"] = "/tampered-root" + restored = client.deserialize_session_state(payload) + + with pytest.raises(ValueError, match="manifest root does not match"): + restored.rebind_persisted_mount_credentials(trusted_manifest) + + assert "trusted-access" not in restored.model_dump_json() + assert "trusted-secret" not in restored.model_dump_json() + @pytest.mark.parametrize("mutation", ["extra", "missing", "tampered"]) def test_rebind_rejects_complete_mount_topology_changes(self, mutation: str) -> None: client = _RoundTripClient() @@ -634,14 +1060,14 @@ def test_rebind_rejects_complete_mount_topology_changes(self, mutation: str) -> bucket="trusted-bucket", access_key_id="trusted-access", secret_access_key="trusted-secret", - mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), ), "public": S3Mount( bucket="public-bucket", mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), ), } - ).with_in_container_mount_credential_exposure_allowed("remote") + ) state = _SimpleSessionState( manifest=trusted_manifest, snapshot=NoopSnapshot(id="snapshot"), @@ -680,7 +1106,7 @@ def test_serialization_rejects_manifest_owned_rclone_config_content(self) -> Non ), ), } - ).with_in_container_mount_credential_exposure_allowed("remote") + ) state = _SimpleSessionState( manifest=manifest, snapshot=NoopSnapshot(id="snapshot"), @@ -691,6 +1117,32 @@ def test_serialization_rejects_manifest_owned_rclone_config_content(self) -> Non assert sentinel not in repr(exc_info.value) + def test_serialization_rejects_opaque_credential_commands_with_manifest_files(self) -> None: + client = _RoundTripClient() + sentinel = "OPAQUE_COMMAND_FILE_SECRET_SENTINEL" + state = _SimpleSessionState( + manifest=Manifest( + entries={ + "credentials.json": File(content=sentinel.encode()), + "remote": S3Mount( + bucket="example", + mount_strategy=DockerVolumeMountStrategy( + driver="rclone", + driver_options={ + "sftp-ssh": "sh -c 'ssh -i credentials.json \"$@\"' --" + }, + ), + ), + } + ), + snapshot=NoopSnapshot(id="snapshot"), + ) + + with pytest.raises(MountConfigError, match="opaque mount credential commands") as exc_info: + client.serialize_session_state(state) + + assert sentinel not in repr(exc_info.value) + def test_raw_state_rejects_manifest_owned_rclone_config_content(self) -> None: client = _RoundTripClient() sentinel = "LEGACY_RCLONE_SECRET_SENTINEL" @@ -708,7 +1160,49 @@ def test_raw_state_rejects_manifest_owned_rclone_config_content(self) -> None: ), snapshot=NoopSnapshot(id="snapshot"), ) - payload = cast(dict[str, object], state.model_dump(mode="json")) + payload = cast(dict[str, object], state.model_dump(mode="json", exclude={"manifest"})) + payload["manifest"] = state.manifest.model_dump(mode="json") + + with pytest.raises(ValueError, match="must not be a manifest entry") as parse_error: + SandboxSessionState.parse(payload) + with pytest.raises(ValueError, match="must not be a manifest entry") as client_error: + client.deserialize_session_state(payload) + + assert sentinel not in repr(parse_error.value) + assert sentinel not in repr(client_error.value) + + @pytest.mark.parametrize( + "mount", + [ + GCSMount( + bucket="example", + service_account_file="credentials.json", + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ), + BoxMount( + box_config_file="credentials.json", + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ), + ], + ids=["gcs", "box"], + ) + def test_raw_state_rejects_manifest_owned_provider_credential_files( + self, + mount: GCSMount | BoxMount, + ) -> None: + client = _RoundTripClient() + sentinel = "LEGACY_PROVIDER_FILE_SECRET_SENTINEL" + state = _SimpleSessionState( + manifest=Manifest( + entries={ + "credentials.json": File(content=sentinel.encode()), + "remote": mount, + } + ), + snapshot=NoopSnapshot(id="snapshot"), + ) + payload = cast(dict[str, object], state.model_dump(mode="json", exclude={"manifest"})) + payload["manifest"] = state.manifest.model_dump(mode="json") with pytest.raises(ValueError, match="must not be a manifest entry") as parse_error: SandboxSessionState.parse(payload) @@ -720,7 +1214,11 @@ def test_raw_state_rejects_manifest_owned_rclone_config_content(self) -> None: @pytest.mark.parametrize( "marker", - ["MARKER_SECRET_SENTINEL", ["MARKER_SECRET_SENTINEL"]], + [ + "MARKER_SECRET_SENTINEL", + ["MARKER_SECRET_SENTINEL"], + {"remote": ["MARKER_SECRET_SENTINEL"]}, + ], ) def test_raw_state_rejects_invalid_mount_credential_markers_without_values( self, @@ -749,6 +1247,81 @@ def test_raw_state_rejects_invalid_mount_credential_markers_without_values( assert "MARKER_SECRET_SENTINEL" not in repr(parse_error.value) assert "MARKER_SECRET_SENTINEL" not in repr(client_error.value) + @pytest.mark.parametrize("manifest_alias", ["Manifest", "manifest ", " manifest", "manifest."]) + @pytest.mark.parametrize("include_canonical_manifest", [False, True]) + def test_direct_readers_reject_noncanonical_manifest_field_names( + self, + manifest_alias: str, + include_canonical_manifest: bool, + ) -> None: + client = _RoundTripClient() + sentinel = "DIRECT_MANIFEST_ALIAS_SECRET_SENTINEL" + payload: dict[str, object] = { + "type": "simple-roundtrip", + manifest_alias: { + "entries": { + "remote": { + "type": "s3_mount", + "access_key_id": sentinel, + } + } + }, + "snapshot": {"type": "noop", "id": "snapshot"}, + } + if include_canonical_manifest: + payload["manifest"] = {"entries": {}} + + with pytest.raises(ValueError, match="invalid manifest field name") as parse_error: + SandboxSessionState.parse(payload) + with pytest.raises(ValueError, match="invalid manifest field name") as client_error: + client.deserialize_session_state(payload) + + assert sentinel not in repr(parse_error.value) + assert sentinel not in repr(client_error.value) + + def test_extension_credential_identity_uses_fixed_raw_marker( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setattr(BaseEntry, "_subclass_registry", dict(BaseEntry._subclass_registry)) + + class _ExtensionCredentialMount(S3Mount): + type: Literal["extension_credential_mount"] = "extension_credential_mount" # type: ignore[assignment] + _credential_field_names: ClassVar[frozenset[str]] = frozenset( + {"credential_identity_secret_sentinel"} + ) + credential_identity_secret_sentinel: str | None = None + + sentinel = "EXTENSION_CREDENTIAL_VALUE_SENTINEL" + trusted_manifest = Manifest( + entries={ + "remote": _ExtensionCredentialMount( + bucket="example", + credential_identity_secret_sentinel=sentinel, + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ) + } + ) + state = _SimpleSessionState( + manifest=trusted_manifest, + snapshot=NoopSnapshot(id="snapshot"), + ) + + payload = cast(dict[str, object], state.model_dump(mode="json")) + assert sentinel not in json.dumps(payload) + assert payload["__openai_agents_redacted_mount_credential_paths"] == { + "remote": ["mount.raw_credential"] + } + + tampered_payload = json.loads(json.dumps(payload)) + tampered_payload["__openai_agents_redacted_mount_credential_paths"] = { + "remote": ["credential_identity_secret_sentinel"] + } + with pytest.raises(ValueError, match="mount credential marker") as exc_info: + SandboxSessionState.parse(tampered_payload) + + assert "credential_identity_secret_sentinel" not in repr(exc_info.value) + def test_client_state_roundtrip_does_not_deepcopy_extension_state(self) -> None: client = _RoundTripClient() trusted_manifest = Manifest( diff --git a/tests/test_run_state.py b/tests/test_run_state.py index bffe069195..de7fafdff8 100644 --- a/tests/test_run_state.py +++ b/tests/test_run_state.py @@ -6,13 +6,16 @@ import io import json import logging +import subprocess +import sys +import textwrap from collections.abc import AsyncIterator, Callable, Mapping from copy import deepcopy from dataclasses import dataclass from datetime import datetime from pathlib import Path from types import SimpleNamespace -from typing import Any, TypeVar, cast +from typing import Any, Literal, TypeVar, cast import pytest from openai.types.responses import ( @@ -105,7 +108,15 @@ ) from agents.sandbox import Manifest from agents.sandbox.capabilities.capability import Capability -from agents.sandbox.entries import File, InContainerMountStrategy, RcloneMountPattern, S3Mount +from agents.sandbox.entries import ( + BoxMount, + DockerVolumeMountStrategy, + File, + GCSMount, + InContainerMountStrategy, + RcloneMountPattern, + S3Mount, +) from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient, UnixLocalSandboxSessionState from agents.sandbox.session.base_sandbox_session import BaseSandboxSession from agents.sandbox.snapshot import LocalSnapshot, NoopSnapshot @@ -155,6 +166,12 @@ run_and_resume_with_mutation, ) + +class _RunStateCredentialDockerStrategy(DockerVolumeMountStrategy): + type: Literal["test_run_state_credential_docker"] = "test_run_state_credential_docker" # type: ignore[assignment] + api_key: str | None = None + + _CURRENT_SCHEMA_MAJOR, _CURRENT_SCHEMA_MINOR = CURRENT_SCHEMA_VERSION.split(".") _NEXT_UNSUPPORTED_SCHEMA_VERSION = f"{_CURRENT_SCHEMA_MAJOR}.{int(_CURRENT_SCHEMA_MINOR) + 1}" @@ -5588,6 +5605,7 @@ def test_supported_schema_versions_match_released_boundary(self): "1.11", "1.12", "1.13", + "1.14", CURRENT_SCHEMA_VERSION, } ) @@ -6103,7 +6121,73 @@ async def test_released_run_state_sandbox_credentials_are_redacted_before_reemis "type": "in_container", "pattern": {"type": "rclone"}, }, - } + }, + "docker-remote": { + "type": "s3_mount", + "bucket": "example", + "mount_strategy": { + "type": "docker_volume", + "driver": "rclone", + "driver_options": { + "s3-secret-access-key": "LEGACY_DRIVER_SECRET_SENTINEL", + "api_key": "LEGACY_DRIVER_API_KEY_SENTINEL", + "apiKey": "LEGACY_DRIVER_CAMEL_API_KEY_SENTINEL", + "api.key": "LEGACY_DRIVER_DOT_API_KEY_SENTINEL", + "secret.access.key": "LEGACY_DRIVER_DOT_SECRET_KEY_SENTINEL", + "password.file": "LEGACY_DRIVER_DOT_PASSWORD_FILE_SENTINEL", + "token.path": "LEGACY_DRIVER_DOT_TOKEN_PATH_SENTINEL", + "s3-profile": "LEGACY_S3_PROFILE_SENTINEL", + "s3-shared-credentials-file": ( + "LEGACY_S3_SHARED_CREDENTIALS_SENTINEL" + ), + "s3-sse-customer-key": "LEGACY_S3_SSE_KEY_SENTINEL", + "s3-sse-customer-key-base64": ("LEGACY_S3_SSE_BASE64_SENTINEL"), + "sftp-pass": "LEGACY_RCLONE_SFTP_PASS_SENTINEL", + "b2-key": "LEGACY_RCLONE_B2_KEY_SENTINEL", + "sftp-key-file": "LEGACY_RCLONE_SFTP_KEY_FILE_SENTINEL", + "sftp-ssh": "LEGACY_RCLONE_SFTP_SSH_SENTINEL", + "http-proxy": ( + "https://user:LEGACY_RCLONE_PROXY_SENTINEL@proxy.example" + ), + "crypt-password2": "LEGACY_RCLONE_CRYPT_PASSWORD_SENTINEL", + "storj-access-grant": "LEGACY_RCLONE_STORJ_GRANT_SENTINEL", + "remote": ( + ":s3,access_key_id=inline," + "secret_access_key=LEGACY_RCLONE_REMOTE_SENTINEL:bucket" + ), + "fs": "LEGACY_RCLONE_FS_SENTINEL:bucket", + "vfs-cache-mode": "off", + }, + }, + }, + "azure-remote": { + "type": "azure_blob_mount", + "account": "account", + "container": "container", + "mount_strategy": { + "type": "docker_volume", + "driver": "rclone", + "driver_options": { + "azureblob-client-certificate-password": ( + "LEGACY_AZURE_CERTIFICATE_PASSWORD_SENTINEL" + ), + "azureblob-client-certificate-path": ( + "LEGACY_AZURE_CERTIFICATE_PATH_SENTINEL" + ), + "azureblob-client-secret": "LEGACY_AZURE_CLIENT_SECRET_SENTINEL", + "azureblob-connection-string": ( + "LEGACY_AZURE_CONNECTION_STRING_SENTINEL" + ), + "azureblob-key": "LEGACY_AZURE_DRIVER_KEY_SENTINEL", + "azureblob-password": "LEGACY_AZURE_PASSWORD_SENTINEL", + "azureblob-sas-url": "LEGACY_AZURE_SAS_URL_SENTINEL", + "azureblob-service-principal-file": ( + "LEGACY_AZURE_PRINCIPAL_FILE_SENTINEL" + ), + "vfs-cache-mode": "off", + }, + }, + }, } }, "snapshot": {"type": "noop", "id": "snapshot"}, @@ -6128,20 +6212,46 @@ async def test_released_run_state_sandbox_credentials_are_redacted_before_reemis assert emitted["$schemaVersion"] == CURRENT_SCHEMA_VERSION assert "LEGACY_RUNSTATE_ACCESS_SENTINEL" not in encoded assert "LEGACY_RUNSTATE_SECRET_SENTINEL" not in encoded + assert "LEGACY_DRIVER_SECRET_SENTINEL" not in encoded + assert "LEGACY_DRIVER_API_KEY_SENTINEL" not in encoded + assert "LEGACY_DRIVER_CAMEL_API_KEY_SENTINEL" not in encoded + assert "LEGACY_DRIVER_DOT_" not in encoded + assert "LEGACY_S3_" not in encoded + assert "LEGACY_RCLONE_" not in encoded + assert "LEGACY_AZURE_" not in encoded sandbox_payload = cast(dict[str, object], emitted["sandbox"]) top_level_state = cast(dict[str, object], sandbox_payload["session_state"]) sessions_by_agent = cast(dict[str, object], sandbox_payload["sessions_by_agent"]) inactive_entry = cast(dict[str, object], sessions_by_agent["inactive-agent"]) inactive_state = cast(dict[str, object], inactive_entry["session_state"]) - expected_marker = ["remote"] + expected_marker = { + "remote": ["access_key_id", "secret_access_key"], + "docker-remote": ["mount_strategy.driver_options"], + "azure-remote": ["mount_strategy.driver_options"], + } assert top_level_state["__openai_agents_redacted_mount_credential_paths"] == expected_marker assert inactive_state["__openai_agents_redacted_mount_credential_paths"] == expected_marker + top_manifest = cast(dict[str, object], top_level_state["manifest"]) + top_entries = cast(dict[str, object], top_manifest["entries"]) + docker_mount = cast(dict[str, object], top_entries["docker-remote"]) + docker_strategy = cast(dict[str, object], docker_mount["mount_strategy"]) + assert docker_strategy["driver_options"] == {"vfs-cache-mode": "off"} + @pytest.mark.parametrize("location", ["active", "inactive"]) + @pytest.mark.parametrize( + "driver_options", + [ + {"dump": "auth"}, + {"dump-bodies": "true"}, + {"dump-headers": "1"}, + {"rc": "true"}, + ], + ) @pytest.mark.asyncio - @pytest.mark.parametrize("legacy", [False, True]) - async def test_released_credentialless_mount_nulls_do_not_require_rebind( + async def test_run_state_rejects_rclone_driver_exposure_options( self, - legacy: bool, + location: str, + driver_options: dict[str, str], ) -> None: agent = Agent(name="TestAgent") state: RunState[Any, Agent[Any]] = make_state( @@ -6149,133 +6259,1055 @@ async def test_released_credentialless_mount_nulls_do_not_require_rebind( context=RunContextWrapper(context={}), original_input="test", ) - session_state = UnixLocalSandboxSessionState( - manifest=Manifest( - entries={ - "remote": S3Mount( - bucket="example", - mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), - ) + session_state: dict[str, object] = { + "type": "unimported_backend", + "manifest": { + "entries": { + "remote": { + "type": "s3_mount", + "bucket": "example", + "mount_strategy": { + "type": "docker_volume", + "driver": "rclone", + "driver_options": driver_options, + }, + } } - ), - snapshot=NoopSnapshot(id="snapshot"), - ).model_dump(mode="json") + }, + "snapshot": {"type": "noop", "id": "snapshot"}, + } + sandbox: dict[str, object] = {"backend_id": "unimported_backend"} + if location == "active": + sandbox["session_state"] = session_state + else: + sandbox["sessions_by_agent"] = { + "inactive-agent": { + "agent_name": "InactiveAgent", + "session_state": session_state, + } + } payload = state.to_json() payload["$schemaVersion"] = "1.13" - payload["sandbox"] = ( - {"sessions_by_agent": {"inactive-agent": session_state}} - if legacy - else {"session_state": session_state} - ) + payload["sandbox"] = sandbox - restored = await RunState.from_json(agent, payload) - emitted = restored.to_json() - - assert "__openai_agents_redacted_mount_credential_paths" not in json.dumps(emitted) + with pytest.raises(ValueError, match="cannot expose credentials"): + await RunState.from_json(agent, payload) @pytest.mark.asyncio + @pytest.mark.parametrize("location", ["active", "inactive"]) @pytest.mark.parametrize( - "sandbox_payload", + "option_name", [ - ["MALFORMED_RUNSTATE_SECRET_SENTINEL"], - {"session_state": ["MALFORMED_RUNSTATE_SECRET_SENTINEL"]}, - {"sessions_by_agent": ["MALFORMED_RUNSTATE_SECRET_SENTINEL"]}, - {"sessions_by_agent": {"inactive-agent": ["MALFORMED_RUNSTATE_SECRET_SENTINEL"]}}, - { - "sessions_by_agent": { - "inactive-agent": {"session_state": ["MALFORMED_RUNSTATE_SECRET_SENTINEL"]} + "apiKeyFile", + "apikeyfile", + "client-key", + "metrics-cert", + "password.file", + "password-command", + "rc-key", + "remote", + "fs", + "token.path", + "webdav-bearer-token-command", + "s3-shared-credentials-file", + "sftp-key-file", + "sftp-ssh", + "smb-kerberos-ccache", + ], + ) + async def test_run_state_rejects_driver_credential_manifest_file( + self, + location: str, + option_name: str, + ) -> None: + agent = Agent(name="TestAgent") + state: RunState[Any, Agent[Any]] = make_state( + agent, + context=RunContextWrapper(context={}), + original_input="test", + ) + sentinel = "RAW_CAMEL_DRIVER_CREDENTIAL_FILE_SECRET_SENTINEL" + credential_filename = ( + "-credentials" if option_name == "webdav-bearer-token-command" else "credentials.json" + ) + payload = state.to_json() + payload["$schemaVersion"] = "1.13" + session_state = { + "type": "unix_local", + "manifest": { + "entries": { + credential_filename: { + "type": "file", + "content": sentinel, + }, + "remote": { + "type": "s3_mount", + "bucket": "example", + "mount_strategy": { + "type": "docker_volume", + "driver": "rclone", + "driver_options": { + option_name: ( + "sh -c 'ssh -i credentials.json \"$@\"' --" + if option_name == "sftp-ssh" + else ( + "cat credentials.json" + if option_name == "password-command" + else ( + "cat -- -credentials" + if option_name == "webdav-bearer-token-command" + else credential_filename + ) + ) + ) + }, + }, + }, } }, - { - "session_state": { - "session_state": { - "type": "unix_local", - "manifest": { - "entries": { - "remote": { - "type": "s3_mount", - "access_key_id": "MALFORMED_RUNSTATE_SECRET_SENTINEL", - } - } - }, - "snapshot": {"type": "noop", "id": "snapshot"}, - } + "snapshot": {"type": "noop", "id": "snapshot"}, + } + sandbox: dict[str, object] = {"backend_id": "unix_local"} + if location == "active": + sandbox["session_state"] = session_state + else: + sandbox["sessions_by_agent"] = { + "inactive-agent": { + "agent_name": "InactiveAgent", + "session_state": session_state, } + } + payload["sandbox"] = sandbox + + with pytest.raises(ValueError, match="manifest") as exc_info: + await RunState.from_json(agent, payload) + + assert sentinel not in repr(exc_info.value) + + @pytest.mark.parametrize("location", ["active", "inactive"]) + @pytest.mark.asyncio + async def test_run_state_rejects_malformed_manifest_root_before_backend_import( + self, + location: str, + ) -> None: + agent = Agent(name="TestAgent") + state: RunState[Any, Agent[Any]] = make_state( + agent, + context=RunContextWrapper(context={}), + original_input="test", + ) + sentinel = "MALFORMED_MANIFEST_ROOT_SECRET_SENTINEL" + session_state: dict[str, object] = { + "type": "unimported_backend", + "manifest": { + "root": {"access_key_id": sentinel}, + "entries": {}, }, - { - "sessions_by_agent": { - "inactive-agent": { - "agent_name": "InactiveAgent", - "session_state": { - "session_state": { - "type": "unix_local", - "manifest": { - "entries": { - "remote": { - "type": "s3_mount", - "access_key_id": "MALFORMED_RUNSTATE_SECRET_SENTINEL", - } - } - }, - "snapshot": {"type": "noop", "id": "snapshot"}, - } + "snapshot": {"type": "noop", "id": "snapshot"}, + } + sandbox: dict[str, object] = {"backend_id": "unimported_backend"} + if location == "active": + sandbox["session_state"] = session_state + else: + sandbox["sessions_by_agent"] = { + "inactive-agent": { + "agent_name": "InactiveAgent", + "session_state": session_state, + } + } + payload = state.to_json() + payload["$schemaVersion"] = "1.13" + payload["sandbox"] = sandbox + + with pytest.raises(ValueError, match="manifest root has an invalid shape") as exc_info: + await RunState.from_json(agent, payload) + + assert sentinel not in repr(exc_info.value) + + @pytest.mark.parametrize("location", ["active", "inactive"]) + @pytest.mark.parametrize( + "extra_args", + [ + ["--config", "RUN_STATE_RCLONE_EXTRA_ARGS_SENTINEL"], + ["--s3-secret-access-key=RUN_STATE_RCLONE_EXTRA_ARGS_SENTINEL"], + ["--s3-shared-credentials-file", "RUN_STATE_RCLONE_EXTRA_ARGS_SENTINEL"], + ["--s3-profile=RUN_STATE_RCLONE_EXTRA_ARGS_SENTINEL"], + ["--header", "Authorization: Bearer RUN_STATE_RCLONE_EXTRA_ARGS_SENTINEL"], + ["--http-proxy=https://user:RUN_STATE_RCLONE_EXTRA_ARGS_SENTINEL@proxy"], + ["--client-key", "RUN_STATE_RCLONE_EXTRA_ARGS_SENTINEL"], + ["--dump-headers"], + ["--s3-sse-customer-key=RUN_STATE_RCLONE_EXTRA_ARGS_SENTINEL"], + ["--rc", "--rc-no-auth"], + ["--rc=true", "--rc-no-auth"], + ["--sftp-pass=RUN_STATE_RCLONE_EXTRA_ARGS_SENTINEL"], + ["--b2-key", "RUN_STATE_RCLONE_EXTRA_ARGS_SENTINEL"], + ["--sftp-key-file=RUN_STATE_RCLONE_EXTRA_ARGS_SENTINEL"], + ["--crypt-password2", "RUN_STATE_RCLONE_EXTRA_ARGS_SENTINEL"], + ["--storj-access-grant=RUN_STATE_RCLONE_EXTRA_ARGS_SENTINEL"], + ["--sftp-ssh", "sshpass -p RUN_STATE_RCLONE_EXTRA_ARGS_SENTINEL ssh"], + ["--sftp-ssh=ssh -i /workspace/RUN_STATE_RCLONE_EXTRA_ARGS_SENTINEL"], + ], + ) + @pytest.mark.asyncio + async def test_run_state_rejects_rclone_credential_extra_args_before_backend_import( + self, + location: str, + extra_args: list[str], + ) -> None: + agent = Agent(name="TestAgent") + state: RunState[Any, Agent[Any]] = make_state( + agent, + context=RunContextWrapper(context={}), + original_input="test", + ) + session_state: dict[str, object] = { + "type": "unimported_backend", + "manifest": { + "entries": { + "remote": { + "type": "s3_mount", + "bucket": "example", + "mount_strategy": { + "type": "in_container", + "pattern": {"type": "rclone", "extra_args": extra_args}, }, } } }, - { - "session_state": { - "type": "unix_local", - "manifest": {"entries": {}}, - "snapshot": {"type": "noop", "id": "snapshot"}, - "sessions_by_agent": { - "nested": { - "manifest": { - "entries": { - "remote": { - "type": "s3_mount", - "access_key_id": "MALFORMED_RUNSTATE_SECRET_SENTINEL", - } - } - } - } - }, + "snapshot": {"type": "noop", "id": "snapshot"}, + } + sandbox: dict[str, object] = {"backend_id": "unimported_backend"} + if location == "active": + sandbox["session_state"] = session_state + else: + sandbox["sessions_by_agent"] = { + "inactive-agent": { + "agent_name": "InactiveAgent", + "session_state": session_state, } - }, - { - "sessions_by_agent": { - "inactive-agent": { - "agent_name": "InactiveAgent", - "session_state": { - "type": "unix_local", - "manifest": {"entries": {}}, - "snapshot": {"type": "noop", "id": "snapshot"}, - "sessions_by_agent": { - "nested": { - "manifest": { - "entries": { - "remote": { - "type": "s3_mount", - "access_key_id": ( - "MALFORMED_RUNSTATE_SECRET_SENTINEL" - ), - } - } - } - } - }, + } + payload = state.to_json() + payload["$schemaVersion"] = "1.13" + payload["sandbox"] = sandbox + + with pytest.raises(ValueError, match="extra_args cannot configure") as exc_info: + await RunState.from_json(agent, payload) + + assert "RUN_STATE_RCLONE_EXTRA_ARGS_SENTINEL" not in repr(exc_info.value) + + @pytest.mark.parametrize("location", ["active", "inactive"]) + @pytest.mark.parametrize("mount_type", ["late_custom_mount", "s3_mount"]) + @pytest.mark.asyncio + async def test_run_state_redacts_unregistered_mount_strategy_fields_before_backend_import( + self, + location: str, + mount_type: str, + ) -> None: + agent = Agent(name="TestAgent") + state: RunState[Any, Agent[Any]] = make_state( + agent, + context=RunContextWrapper(context={}), + original_input="test", + ) + sentinel = "RUN_STATE_UNREGISTERED_STRATEGY_SENTINEL" + session_state: dict[str, object] = { + "type": "unimported_backend", + "manifest": { + "entries": { + "remote": { + "type": mount_type, + "mount_strategy": { + "type": "late_custom_strategy", + "auth_blob": sentinel, + "config": {"token": sentinel}, }, } } }, - { - "session_state": { - "type": "unix_local", - "manifest": {"entries": {}}, - "snapshot": {"type": "noop", "id": "snapshot"}, - "metadata": { - "wrapped": { - "manifest": { + "snapshot": {"type": "noop", "id": "snapshot"}, + } + sandbox: dict[str, object] = {"backend_id": "unimported_backend"} + if location == "active": + sandbox["session_state"] = session_state + else: + sandbox["sessions_by_agent"] = { + "inactive-agent": { + "agent_name": "InactiveAgent", + "session_state": session_state, + } + } + payload = state.to_json() + payload["$schemaVersion"] = "1.13" + payload["sandbox"] = sandbox + + restored = await RunState.from_json(agent, payload) + emitted = restored.to_json() + + assert sentinel not in str(emitted) + emitted_sandbox = cast(dict[str, object], emitted["sandbox"]) + if location == "active": + emitted_state = cast(dict[str, object], emitted_sandbox["session_state"]) + else: + sessions_by_agent = cast(dict[str, object], emitted_sandbox["sessions_by_agent"]) + inactive_entry = cast(dict[str, object], sessions_by_agent["inactive-agent"]) + emitted_state = cast(dict[str, object], inactive_entry["session_state"]) + assert emitted_state["__openai_agents_redacted_mount_credential_paths"] == { + "remote": ["mount.raw_credential"] + } + + @pytest.mark.parametrize("location", ["active", "inactive"]) + @pytest.mark.asyncio + async def test_run_state_redacts_registered_strategy_credentials_before_backend_import( + self, + location: str, + ) -> None: + agent = Agent(name="TestAgent") + state: RunState[Any, Agent[Any]] = make_state( + agent, + context=RunContextWrapper(context={}), + original_input="test", + ) + sentinel = "RUN_STATE_REGISTERED_STRATEGY_CREDENTIAL_SENTINEL" + session_state: dict[str, object] = { + "type": "unimported_backend", + "manifest": { + "entries": { + "remote": { + "type": "s3_mount", + "bucket": "example", + "mount_strategy": { + "type": "test_run_state_credential_docker", + "driver": "rclone", + "api_key": sentinel, + }, + } + } + }, + "snapshot": {"type": "noop", "id": "snapshot"}, + } + sandbox: dict[str, object] = {"backend_id": "unimported_backend"} + if location == "active": + sandbox["session_state"] = session_state + else: + sandbox["sessions_by_agent"] = { + "inactive-agent": { + "agent_name": "InactiveAgent", + "session_state": session_state, + } + } + payload = state.to_json() + payload["$schemaVersion"] = "1.13" + payload["sandbox"] = sandbox + + restored = await RunState.from_json(agent, payload) + emitted = restored.to_json() + + assert sentinel not in str(emitted) + emitted_sandbox = cast(dict[str, object], emitted["sandbox"]) + if location == "active": + emitted_state = cast(dict[str, object], emitted_sandbox["session_state"]) + else: + sessions_by_agent = cast(dict[str, object], emitted_sandbox["sessions_by_agent"]) + inactive_entry = cast(dict[str, object], sessions_by_agent["inactive-agent"]) + emitted_state = cast(dict[str, object], inactive_entry["session_state"]) + assert emitted_state["__openai_agents_redacted_mount_credential_paths"] == { + "remote": ["mount.raw_credential"] + } + + @pytest.mark.parametrize("location", ["active", "inactive"]) + @pytest.mark.parametrize("invalid_type_location", ["mount", "strategy"]) + @pytest.mark.asyncio + async def test_run_state_rejects_unregistered_mount_non_string_types_before_backend_import( + self, + location: str, + invalid_type_location: str, + ) -> None: + agent = Agent(name="TestAgent") + state: RunState[Any, Agent[Any]] = make_state( + agent, + context=RunContextWrapper(context={}), + original_input="test", + ) + sentinel = "RUN_STATE_UNREGISTERED_TYPE_SENTINEL" + mount_type: object = "late_custom_mount" + strategy_type: object = "late_custom_strategy" + if invalid_type_location == "mount": + mount_type = {"secret": sentinel} + else: + strategy_type = {"secret": sentinel} + session_state: dict[str, object] = { + "type": "unimported_backend", + "manifest": { + "entries": { + "remote": { + "type": mount_type, + "mount_strategy": {"type": strategy_type}, + } + } + }, + "snapshot": {"type": "noop", "id": "snapshot"}, + } + sandbox: dict[str, object] = {"backend_id": "unimported_backend"} + if location == "active": + sandbox["session_state"] = session_state + else: + sandbox["sessions_by_agent"] = { + "inactive-agent": { + "agent_name": "InactiveAgent", + "session_state": session_state, + } + } + payload = state.to_json() + payload["$schemaVersion"] = "1.13" + payload["sandbox"] = sandbox + + with pytest.raises(ValueError, match="type must be a string") as exc_info: + await RunState.from_json(agent, payload) + + assert sentinel not in repr(exc_info.value) + + def test_run_state_from_json_does_not_require_sandbox_backend_import(self) -> None: + script = textwrap.dedent( + """ + import asyncio + import copy + + from agents import Agent + from agents.run_state import RunState + from agents.sandbox.session.sandbox_session_state import SandboxSessionState + + assert SandboxSessionState._subclass_registry == {} + + async def main(): + agent = Agent(name="TestAgent") + state = { + "type": "unix_local", + "manifest": { + "entries": { + "remote": { + "type": "s3_mount", + "bucket": "example", + "accessKeyId": "IMPORT_ORDER_ACCESS_SENTINEL", + "secretaccesskey": "IMPORT_ORDER_SECRET_SENTINEL", + "apiKey": "IMPORT_ORDER_API_KEY_SENTINEL", + "password": "IMPORT_ORDER_PASSWORD_SENTINEL", + "privateKey": "IMPORT_ORDER_PRIVATE_KEY_SENTINEL", + "apiKeyFile": "IMPORT_ORDER_API_KEY_FILE_SENTINEL", + "mount_strategy": { + "type": "in_container", + "pattern": {"type": "rclone"}, + }, + } + } + }, + "snapshot": {"type": "noop", "id": "snapshot"}, + } + payload = { + "$schemaVersion": "1.13", + "original_input": "test", + "current_agent": {"name": "TestAgent"}, + "context": { + "context": {}, + "usage": { + "requests": 0, + "input_tokens": 0, + "output_tokens": 0, + "total_tokens": 0, + }, + "approvals": {}, + }, + "max_turns": 3, + "current_turn": 0, + "model_responses": [], + "generated_items": [], + "sandbox": { + "session_state": copy.deepcopy(state), + "sessions_by_agent": { + "inactive-agent": { + "agent_name": "InactiveAgent", + "session_state": copy.deepcopy(state), + } + }, + }, + } + restored = await RunState.from_json(agent, payload) + assert restored._sandbox["session_state"]["type"] == "unix_local" + emitted = restored.to_json() + assert "IMPORT_ORDER_ACCESS_SENTINEL" not in str(emitted) + assert "IMPORT_ORDER_SECRET_SENTINEL" not in str(emitted) + assert "IMPORT_ORDER_API_KEY_SENTINEL" not in str(emitted) + assert "IMPORT_ORDER_PASSWORD_SENTINEL" not in str(emitted) + assert "IMPORT_ORDER_PRIVATE_KEY_SENTINEL" not in str(emitted) + assert "IMPORT_ORDER_API_KEY_FILE_SENTINEL" not in str(emitted) + expected_marker = { + "remote": [ + "access_key_id", + "mount.raw_credential", + "secret_access_key", + ] + } + assert ( + emitted["sandbox"]["session_state"] + ["__openai_agents_redacted_mount_credential_paths"] + == expected_marker + ) + assert ( + emitted["sandbox"]["sessions_by_agent"]["inactive-agent"]["session_state"] + ["__openai_agents_redacted_mount_credential_paths"] + == expected_marker + ) + + for location, field_name in ( + ("active", "serviceAccountFile"), + ("inactive", "serviceaccountfile"), + ): + credential_state = { + "type": "unix_local", + "manifest": { + "entries": { + "credentials.json": { + "type": "file", + "content": "IMPORT_ORDER_FILE_SENTINEL", + }, + "remote": { + "type": "gcs_mount", + "bucket": "example", + field_name: "credentials.json", + "mount_strategy": { + "type": "in_container", + "pattern": {"type": "rclone"}, + }, + }, + } + }, + "snapshot": {"type": "noop", "id": "snapshot"}, + } + credential_payload = copy.deepcopy(payload) + if location == "active": + credential_payload["sandbox"] = {"session_state": credential_state} + else: + credential_payload["sandbox"] = { + "sessions_by_agent": { + "inactive-agent": { + "agent_name": "InactiveAgent", + "session_state": credential_state, + } + } + } + try: + await RunState.from_json(agent, credential_payload) + except ValueError as error: + assert "must not be a manifest entry" in str(error) + assert "IMPORT_ORDER_FILE_SENTINEL" not in repr(error) + else: + raise AssertionError("credential file alias was not rejected") + + for location, mount_strategy in ( + ( + "active", + { + "type": "docker_volume", + "driver": "rclone", + "driverOptions": { + "secretAccessKey": "STRUCTURAL_ALIAS_SENTINEL" + }, + }, + ), + ( + "inactive", + { + "type": "in_container", + "pattern": { + "type": "rclone", + "configFilePath": "STRUCTURAL_ALIAS_SENTINEL", + }, + }, + ), + ): + alias_state = { + "type": "unix_local", + "manifest": { + "entries": { + "remote": { + "type": "s3_mount", + "bucket": "example", + "mount_strategy": mount_strategy, + } + } + }, + "snapshot": {"type": "noop", "id": "snapshot"}, + } + alias_payload = copy.deepcopy(payload) + if location == "active": + alias_payload["sandbox"] = {"session_state": alias_state} + else: + alias_payload["sandbox"] = { + "sessions_by_agent": { + "inactive-agent": { + "agent_name": "InactiveAgent", + "session_state": alias_state, + } + } + } + try: + await RunState.from_json(agent, alias_payload) + except ValueError as error: + assert "invalid field name" in str(error) + assert "STRUCTURAL_ALIAS_SENTINEL" not in repr(error) + else: + raise AssertionError("credential-bearing structural alias was not rejected") + + extension_payload = copy.deepcopy(payload) + extension_payload["sandbox"] = { + "session_state": { + "type": "unimported_backend", + "manifest": { + "entries": { + "extension": { + "type": "late_custom_entry", + "metadata": "opaque-extension-value", + } + } + }, + "snapshot": {"type": "noop", "id": "snapshot"}, + } + } + extension_state = await RunState.from_json(agent, extension_payload) + extension_emitted = extension_state.to_json() + assert "opaque-extension-value" in str(extension_emitted) + assert "__openai_agents_redacted_mount_credential_paths" not in str( + extension_emitted + ) + + credential_extension_payload = copy.deepcopy(payload) + credential_extension_payload["sandbox"] = { + "session_state": { + "type": "unimported_backend", + "manifest": { + "entries": { + "extension": { + "type": "late_custom_entry", + "token": "UNKNOWN_ENTRY_CREDENTIAL_SENTINEL", + } + } + }, + "snapshot": {"type": "noop", "id": "snapshot"}, + } + } + try: + await RunState.from_json(agent, credential_extension_payload) + except ValueError as error: + assert "credential-like fields" in str(error) + assert "UNKNOWN_ENTRY_CREDENTIAL_SENTINEL" not in repr(error) + else: + raise AssertionError("credential-bearing unknown entry was not rejected") + + extension_mount_payload = copy.deepcopy(payload) + extension_mount_payload["sandbox"] = { + "session_state": { + "type": "unimported_backend", + "manifest": { + "entries": { + "extension": { + "type": "late_custom_mount", + "description": "Preserved structural metadata", + "opaque_auth_blob": "UNIMPORTED_MOUNT_AUTH_SENTINEL", + "mount_strategy": { + "type": "in_container", + "pattern": {"type": "rclone"}, + }, + } + } + }, + "snapshot": {"type": "noop", "id": "snapshot"}, + } + } + extension_mount_state = await RunState.from_json(agent, extension_mount_payload) + extension_mount_emitted = extension_mount_state.to_json() + assert "UNIMPORTED_MOUNT_AUTH_SENTINEL" not in str(extension_mount_emitted) + emitted_session_state = extension_mount_emitted["sandbox"]["session_state"] + emitted_manifest = emitted_session_state["manifest"] + emitted_extension = emitted_manifest["entries"]["extension"] + assert emitted_extension["description"] == "Preserved structural metadata" + assert emitted_session_state[ + "__openai_agents_redacted_mount_credential_paths" + ] == {"extension": ["mount.raw_credential"]} + + asyncio.run(main()) + """ + ) + + completed = subprocess.run( + [sys.executable, "-c", script], + check=False, + capture_output=True, + text=True, + timeout=30, + ) + + assert completed.returncode == 0, completed.stderr + + def test_run_state_preserves_released_modal_references_before_extension_import(self) -> None: + script = textwrap.dedent( + """ + import asyncio + + from agents import Agent + from agents.run_state import RunState + from agents.sandbox.entries.mounts.base import MountStrategyBase + + assert "modal_cloud_bucket" not in MountStrategyBase._subclass_registry + + async def main(): + agent = Agent(name="TestAgent") + payload = { + "$schemaVersion": "1.13", + "original_input": "test", + "current_agent": {"name": "TestAgent"}, + "context": { + "context": {}, + "usage": { + "requests": 0, + "input_tokens": 0, + "output_tokens": 0, + "total_tokens": 0, + }, + "approvals": {}, + }, + "max_turns": 3, + "current_turn": 0, + "model_responses": [], + "generated_items": [], + "sandbox": { + "session_state": { + "type": "unimported_backend", + "manifest": { + "entries": { + "remote": { + "type": "s3_mount", + "bucket": "example", + "mount_strategy": { + "type": "modal_cloud_bucket", + "secret_name": "named-secret", + "secret_environment_name": "staging", + }, + } + } + }, + "snapshot": {"type": "noop", "id": "snapshot"}, + } + }, + } + + restored = await RunState.from_json(agent, payload) + emitted = restored.to_json() + emitted_state = emitted["sandbox"]["session_state"] + emitted_strategy = emitted_state["manifest"]["entries"]["remote"][ + "mount_strategy" + ] + assert emitted_strategy == { + "type": "modal_cloud_bucket", + "secret_name": "named-secret", + "secret_environment_name": "staging", + } + assert "__openai_agents_redacted_mount_credential_paths" not in emitted_state + + from agents.extensions.sandbox.modal.mounts import ( + ModalCloudBucketMountStrategy, + ) + from agents.sandbox.manifest import Manifest + + parsed_manifest = Manifest.model_validate(emitted_state["manifest"]) + parsed_strategy = parsed_manifest.entries["remote"].mount_strategy + assert isinstance(parsed_strategy, ModalCloudBucketMountStrategy) + assert parsed_strategy.secret_name == "named-secret" + assert parsed_strategy.secret_environment_name == "staging" + + asyncio.run(main()) + """ + ) + + completed = subprocess.run( + [sys.executable, "-c", script], + check=False, + capture_output=True, + text=True, + timeout=30, + ) + + assert completed.returncode == 0, completed.stderr + + def test_run_state_preserves_released_extension_mounts_before_extension_import(self) -> None: + from agents.extensions.sandbox.blaxel.mounts import ( + BlaxelDriveMount, + BlaxelDriveMountStrategy, + ) + + blaxel_entry_json = json.dumps( + BlaxelDriveMount( + description="released drive", + mount_path=Path("/workspace/drive-alias"), + read_only=False, + drive_name="shared-drive", + drive_mount_path="/workspace/drive", + drive_path="/datasets", + drive_read_only=True, + mount_strategy=BlaxelDriveMountStrategy(), + ).model_dump(mode="json") + ) + script = textwrap.dedent( + """ + import asyncio + import copy + import json + + from agents import Agent + from agents.run_state import RunState + from agents.sandbox.entries.base import BaseEntry + from agents.sandbox.entries.mounts.base import MountStrategyBase + + assert "blaxel_drive_mount" not in BaseEntry.registered_types() + for strategy_type in ( + "blaxel_drive", + "daytona_cloud_bucket", + "e2b_cloud_bucket", + "runloop_cloud_bucket", + ): + assert strategy_type not in MountStrategyBase._subclass_registry + + released_entries = { + "blaxel": json.loads(__BLAXEL_ENTRY_JSON__), + "daytona": { + "type": "s3_mount", + "bucket": "example", + "mount_strategy": { + "type": "daytona_cloud_bucket", + "pattern": { + "type": "rclone", + "mode": "nfs", + "nfs_addr": "127.0.0.1:2049", + "extra_args": ["--vfs-cache-mode", "off"], + }, + }, + }, + "e2b": { + "type": "s3_mount", + "bucket": "example", + "mount_strategy": { + "type": "e2b_cloud_bucket", + "pattern": {"type": "rclone", "mode": "fuse"}, + }, + }, + "runloop": { + "type": "s3_mount", + "bucket": "example", + "mount_strategy": { + "type": "runloop_cloud_bucket", + "pattern": {"type": "rclone", "mode": "fuse"}, + }, + }, + } + + async def main(): + agent = Agent(name="TestAgent") + base_payload = { + "$schemaVersion": "1.13", + "original_input": "test", + "current_agent": {"name": "TestAgent"}, + "context": { + "context": {}, + "usage": { + "requests": 0, + "input_tokens": 0, + "output_tokens": 0, + "total_tokens": 0, + }, + "approvals": {}, + }, + "max_turns": 3, + "current_turn": 0, + "model_responses": [], + "generated_items": [], + } + emitted_entries = {} + for provider, entry in released_entries.items(): + payload = copy.deepcopy(base_payload) + payload["sandbox"] = { + "session_state": { + "type": "unimported_backend", + "manifest": {"entries": {"remote": entry}}, + "snapshot": {"type": "noop", "id": "snapshot"}, + } + } + restored = await RunState.from_json(agent, payload) + emitted_state = restored.to_json()["sandbox"]["session_state"] + assert "__openai_agents_redacted_mount_credential_paths" not in emitted_state + emitted_entry = emitted_state["manifest"]["entries"]["remote"] + assert emitted_entry == entry + emitted_entries[provider] = emitted_entry + + import agents.extensions.sandbox.blaxel.mounts + import agents.extensions.sandbox.daytona.mounts + import agents.extensions.sandbox.e2b.mounts + import agents.extensions.sandbox.runloop.mounts + from agents.sandbox.manifest import Manifest + + for entry in emitted_entries.values(): + Manifest.model_validate({"entries": {"remote": entry}}) + + asyncio.run(main()) + """ + ).replace("__BLAXEL_ENTRY_JSON__", repr(blaxel_entry_json)) + + completed = subprocess.run( + [sys.executable, "-c", script], + check=False, + capture_output=True, + text=True, + timeout=30, + ) + + assert completed.returncode == 0, completed.stderr + + @pytest.mark.asyncio + @pytest.mark.parametrize("legacy", [False, True]) + async def test_released_credentialless_mount_nulls_do_not_require_rebind( + self, + legacy: bool, + ) -> None: + agent = Agent(name="TestAgent") + state: RunState[Any, Agent[Any]] = make_state( + agent, + context=RunContextWrapper(context={}), + original_input="test", + ) + session_state = UnixLocalSandboxSessionState( + manifest=Manifest( + entries={ + "remote": S3Mount( + bucket="example", + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ) + } + ), + snapshot=NoopSnapshot(id="snapshot"), + ).model_dump(mode="json") + payload = state.to_json() + payload["$schemaVersion"] = "1.13" + payload["sandbox"] = ( + {"sessions_by_agent": {"inactive-agent": session_state}} + if legacy + else {"session_state": session_state} + ) + + restored = await RunState.from_json(agent, payload) + emitted = restored.to_json() + + assert "__openai_agents_redacted_mount_credential_paths" not in json.dumps(emitted) + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "sandbox_payload", + [ + ["MALFORMED_RUNSTATE_SECRET_SENTINEL"], + {"session_state": ["MALFORMED_RUNSTATE_SECRET_SENTINEL"]}, + {"sessions_by_agent": ["MALFORMED_RUNSTATE_SECRET_SENTINEL"]}, + {"sessions_by_agent": {"inactive-agent": ["MALFORMED_RUNSTATE_SECRET_SENTINEL"]}}, + { + "sessions_by_agent": { + "inactive-agent": {"session_state": ["MALFORMED_RUNSTATE_SECRET_SENTINEL"]} + } + }, + { + "session_state": { + "session_state": { + "type": "unix_local", + "manifest": { + "entries": { + "remote": { + "type": "s3_mount", + "access_key_id": "MALFORMED_RUNSTATE_SECRET_SENTINEL", + } + } + }, + "snapshot": {"type": "noop", "id": "snapshot"}, + } + } + }, + { + "sessions_by_agent": { + "inactive-agent": { + "agent_name": "InactiveAgent", + "session_state": { + "session_state": { + "type": "unix_local", + "manifest": { + "entries": { + "remote": { + "type": "s3_mount", + "access_key_id": "MALFORMED_RUNSTATE_SECRET_SENTINEL", + } + } + }, + "snapshot": {"type": "noop", "id": "snapshot"}, + } + }, + } + } + }, + { + "session_state": { + "type": "unix_local", + "manifest": {"entries": {}}, + "snapshot": {"type": "noop", "id": "snapshot"}, + "sessions_by_agent": { + "nested": { + "manifest": { + "entries": { + "remote": { + "type": "s3_mount", + "access_key_id": "MALFORMED_RUNSTATE_SECRET_SENTINEL", + } + } + } + } + }, + } + }, + { + "sessions_by_agent": { + "inactive-agent": { + "agent_name": "InactiveAgent", + "session_state": { + "type": "unix_local", + "manifest": {"entries": {}}, + "snapshot": {"type": "noop", "id": "snapshot"}, + "sessions_by_agent": { + "nested": { + "manifest": { + "entries": { + "remote": { + "type": "s3_mount", + "access_key_id": ( + "MALFORMED_RUNSTATE_SECRET_SENTINEL" + ), + } + } + } + } + }, + }, + } + } + }, + { + "session_state": { + "type": "unix_local", + "manifest": {"entries": {}}, + "snapshot": {"type": "noop", "id": "snapshot"}, + "metadata": { + "wrapped": { + "manifest": { "entries": { "remote": { "type": "s3_mount", @@ -6405,6 +7437,147 @@ async def test_released_run_state_rejects_invalid_mount_credential_marker(self) assert "MARKER_SECRET_SENTINEL" not in repr(exc_info.value) + @pytest.mark.parametrize("location", ["active", "inactive"]) + @pytest.mark.parametrize("reserved_structure", ["marker", "driver_options"]) + @pytest.mark.asyncio + async def test_released_run_state_rejects_nested_mount_security_structures( + self, + location: str, + reserved_structure: str, + ) -> None: + agent = Agent(name="TestAgent") + state: RunState[Any, Agent[Any]] = make_state( + agent, + context=RunContextWrapper(context={}), + original_input="test", + ) + sentinel = "NESTED_MOUNT_SECURITY_SECRET_SENTINEL" + remote_entry: dict[str, object] = { + "type": "s3_mount", + "bucket": "example", + "mount_strategy": { + "type": "docker_volume", + "driver": "rclone", + }, + } + session_state: dict[str, object] = { + "type": "unimported_backend", + "manifest": {"entries": {"remote": remote_entry}}, + "snapshot": {"type": "noop", "id": "snapshot"}, + } + if reserved_structure == "marker": + session_state["metadata"] = { + "__openai_agents_redacted_mount_credential_paths": sentinel + } + else: + remote_entry["metadata"] = {"driver_options": {"s3-secret-access-key": sentinel}} + sandbox: dict[str, object] = {"backend_id": "unimported_backend"} + if location == "active": + sandbox["session_state"] = session_state + else: + sandbox["sessions_by_agent"] = { + "inactive-agent": { + "agent_name": "InactiveAgent", + "session_state": session_state, + } + } + payload = state.to_json() + payload["$schemaVersion"] = "1.13" + payload["sandbox"] = sandbox + + with pytest.raises(ValueError, match="ambiguous") as exc_info: + await RunState.from_json(agent, payload) + + assert sentinel not in repr(exc_info.value) + + @pytest.mark.asyncio + async def test_released_run_state_rejects_untrusted_mount_credential_identity(self) -> None: + agent = Agent(name="TestAgent") + state: RunState[Any, Agent[Any]] = make_state( + agent, + context=RunContextWrapper(context={}), + original_input="test", + ) + payload = state.to_json() + payload["$schemaVersion"] = "1.13" + payload["sandbox"] = { + "session_state": { + "type": "unix_local", + "manifest": { + "entries": { + "remote": { + "type": "s3_mount", + "bucket": "example", + "mount_strategy": { + "type": "in_container", + "pattern": {"type": "rclone"}, + }, + } + } + }, + "snapshot": {"type": "noop", "id": "snapshot"}, + "__openai_agents_redacted_mount_credential_paths": { + "remote": ["MARKER_SECRET_SENTINEL"] + }, + } + } + + with pytest.raises(ValueError, match="mount credential marker") as exc_info: + await RunState.from_json(agent, payload) + + assert "MARKER_SECRET_SENTINEL" not in repr(exc_info.value) + + @pytest.mark.asyncio + @pytest.mark.parametrize("inactive", [False, True]) + @pytest.mark.parametrize("manifest_alias", ["Manifest", "manifest ", " manifest", "manifest."]) + @pytest.mark.parametrize("include_canonical_manifest", [False, True]) + async def test_released_run_state_rejects_noncanonical_manifest_field_name( + self, + inactive: bool, + manifest_alias: str, + include_canonical_manifest: bool, + ) -> None: + agent = Agent(name="TestAgent") + state: RunState[Any, Agent[Any]] = make_state( + agent, + context=RunContextWrapper(context={}), + original_input="test", + ) + session_state: dict[str, object] = { + "type": "unimported_backend", + manifest_alias: { + "entries": { + "remote": { + "type": "s3_mount", + "access_key_id": "MANIFEST_ALIAS_SECRET_SENTINEL", + } + } + }, + "snapshot": {"type": "noop", "id": "snapshot"}, + } + if include_canonical_manifest: + session_state["manifest"] = {"entries": {}} + sandbox_payload: dict[str, object] + if inactive: + sandbox_payload = { + "sessions_by_agent": { + "inactive-agent": { + "agent_name": "InactiveAgent", + "session_state": session_state, + } + } + } + else: + sandbox_payload = {"session_state": session_state} + payload = state.to_json() + payload["$schemaVersion"] = "1.13" + payload["sandbox"] = sandbox_payload + + with pytest.raises(ValueError, match="invalid manifest field name") as exc_info: + await RunState.from_json(agent, payload) + + assert "MANIFEST_ALIAS_SECRET_SENTINEL" not in repr(exc_info.value) + @pytest.mark.asyncio @pytest.mark.parametrize("inactive", [False, True]) async def test_released_run_state_drops_noncanonical_manifest_metadata( @@ -6476,9 +7649,60 @@ async def test_released_run_state_rejects_manifest_owned_rclone_config(self) -> ) payload = state.to_json() payload["$schemaVersion"] = "1.13" - payload["sandbox"] = { - "session_state": cast(dict[str, object], session_state.model_dump(mode="json")) - } + raw_session_state = cast( + dict[str, object], session_state.model_dump(mode="json", exclude={"manifest"}) + ) + raw_session_state["manifest"] = session_state.manifest.model_dump(mode="json") + payload["sandbox"] = {"session_state": raw_session_state} + + with pytest.raises(ValueError, match="must not be a manifest entry") as exc_info: + await RunState.from_json(agent, payload) + + assert sentinel not in repr(exc_info.value) + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "mount", + [ + GCSMount( + bucket="example", + service_account_file="credentials.json", + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ), + BoxMount( + box_config_file="credentials.json", + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ), + ], + ids=["gcs", "box"], + ) + async def test_released_run_state_rejects_manifest_owned_provider_credential_files( + self, + mount: GCSMount | BoxMount, + ) -> None: + agent = Agent(name="TestAgent") + state: RunState[Any, Agent[Any]] = make_state( + agent, + context=RunContextWrapper(context={}), + original_input="test", + ) + sentinel = "LEGACY_RUNSTATE_PROVIDER_FILE_SECRET_SENTINEL" + session_state = UnixLocalSandboxSessionState( + manifest=Manifest( + entries={ + "credentials.json": File(content=sentinel.encode()), + "remote": mount, + } + ), + snapshot=NoopSnapshot(id="snapshot"), + ) + payload = state.to_json() + payload["$schemaVersion"] = "1.13" + raw_session_state = cast( + dict[str, object], session_state.model_dump(mode="json", exclude={"manifest"}) + ) + raw_session_state["manifest"] = session_state.manifest.model_dump(mode="json") + payload["sandbox"] = {"session_state": raw_session_state} with pytest.raises(ValueError, match="must not be a manifest entry") as exc_info: await RunState.from_json(agent, payload) diff --git a/tests/test_tracing_errors.py b/tests/test_tracing_errors.py index e256f90cc8..99e199308e 100644 --- a/tests/test_tracing_errors.py +++ b/tests/test_tracing_errors.py @@ -21,6 +21,7 @@ TResponseInputItem, _debug, ) +from agents.exceptions import _mark_error_data_redacted from agents.run_internal.error_handlers import attach_generic_agent_error from .fake_model import FakeModel @@ -773,3 +774,18 @@ def test_redacted_tracing_never_stringifies_the_exception(): "message": "Error in agent run", "data": {"error": "Error details are redacted."}, } + + +def test_data_redacted_exception_stays_redacted_when_sensitive_tracing_is_enabled(): + sentinel = "REDACTED_EXCEPTION_MESSAGE_SENTINEL" + span = RecordingSpan() + error = RuntimeError(sentinel) + _mark_error_data_redacted(error) + + attach_generic_agent_error(cast(Any, span), error, trace_include_sensitive_data=True) + + assert sentinel not in repr(span.error) + assert span.error == { + "message": "Error in agent run", + "data": {"error": "Error details are redacted."}, + } From 670ec3cc5d2791140c61a0fbf554393083fd160b Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 7 Aug 2026 16:52:38 +0900 Subject: [PATCH 3/4] fix(sandbox): close mount credential boundary gaps --- .../references/sandbox-runtime-boundary.md | 2 +- .../extensions/sandbox/vercel/sandbox.py | 6 +- src/agents/sandbox/_mount_security.py | 147 ++++++++++++++--- src/agents/sandbox/entries/mounts/base.py | 13 +- src/agents/sandbox/manifest.py | 16 +- src/agents/sandbox/runtime_session_manager.py | 38 +++-- .../sandbox/session/sandbox_session_state.py | 51 ++++-- .../extensions/sandbox/test_blaxel_mounts.py | 4 +- tests/extensions/sandbox/test_vercel.py | 42 +++++ tests/sandbox/test_mounts.py | 47 ++++++ tests/sandbox/test_runtime.py | 103 +++++++++++- tests/sandbox/test_session_state_roundtrip.py | 150 +++++++++++++++++- tests/test_run_state.py | 62 ++++++++ 13 files changed, 619 insertions(+), 62 deletions(-) diff --git a/.agents/references/sandbox-runtime-boundary.md b/.agents/references/sandbox-runtime-boundary.md index a35e2b146e..0e5beb7447 100644 --- a/.agents/references/sandbox-runtime-boundary.md +++ b/.agents/references/sandbox-runtime-boundary.md @@ -35,7 +35,7 @@ Resolve the session source in this order: injected live session, resumable sandb - Validate local sources at use time, not only when parsing the manifest. Defend against symlinked sources, parent-directory swaps, platform path aliases, and archive members that change meaning between validation and extraction. - Archive extraction must reject traversal, unsafe links, and unsupported member types before writing, and enforce entry, byte, and expansion limits without materializing an unbounded member list. - Extra path grants are runtime access, not durable workspace content. Snapshots and `persist_workspace()` include the workspace root, not arbitrary granted paths. -- Credentials for mounts or providers must remain in the owning adapter and must not appear in generated shell commands, model-visible errors, logs, or serialized sandbox state. Reject explicit credentials before side effects when a mount strategy runs helpers inside the model-controlled sandbox. Credential-bearing mounts must use an external or provider-controlled strategy unless trusted application code explicitly acknowledges exposure for a specific path; manifest dictionaries and persisted state cannot carry that acknowledgement. +- Credentials for mounts or providers must remain in the owning adapter and must not appear in generated shell commands, model-visible errors, logs, or serialized sandbox state. Reject explicit credentials before side effects when a mount strategy runs helpers inside the model-controlled sandbox. Credential-bearing in-container helpers are unsupported; credential-bearing mounts must use an external or provider-controlled strategy. ## Provider and Error Boundary diff --git a/src/agents/extensions/sandbox/vercel/sandbox.py b/src/agents/extensions/sandbox/vercel/sandbox.py index 1b94611db9..4cfd8a6f95 100644 --- a/src/agents/extensions/sandbox/vercel/sandbox.py +++ b/src/agents/extensions/sandbox/vercel/sandbox.py @@ -465,6 +465,10 @@ def __init__( allow_s3_credential_exposure: bool = False, trusted_s3_mounts: dict[str, S3Mount] | None = None, ) -> None: + BaseSandboxClient._validate_manifest_mount_credentials( + state.manifest, + state._mount_credential_environment_names(), + ) resolved_trusted_s3_mounts: dict[str, S3Mount] = {} trusted_s3_mount_credentials: dict[ str, @@ -1340,7 +1344,7 @@ async def create( options: VercelSandboxClientOptions, ) -> SandboxSession: resolved_manifest = _resolve_manifest_root(manifest) - self._validate_manifest_mount_credentials(resolved_manifest) + self._validate_manifest_mount_credentials(resolved_manifest, options.env) trusted_s3_mounts = _vercel_s3_mount_map(resolved_manifest) for mount in trusted_s3_mounts.values(): mount.mount_strategy.validate_mount(mount) diff --git a/src/agents/sandbox/_mount_security.py b/src/agents/sandbox/_mount_security.py index 1b6fe36b43..23fb9c8df5 100644 --- a/src/agents/sandbox/_mount_security.py +++ b/src/agents/sandbox/_mount_security.py @@ -83,6 +83,7 @@ "e2b_cloud_bucket", "in_container", "runloop_cloud_bucket", + "vercel_cloud_bucket", } ) _CREDENTIAL_ENVIRONMENT_NAMES = frozenset( @@ -99,8 +100,9 @@ def _credential_like_environment_names(names: object) -> tuple[str, ...]: + candidates: Iterable[object] if isinstance(names, Mapping): - candidates = names + candidates = names.keys() elif isinstance(names, Iterable) and not isinstance(names, str | bytes): candidates = names else: @@ -118,12 +120,14 @@ def _credential_like_environment_names(names: object) -> tuple[str, ...]: def validate_mount_credential_environment_names( manifest: Manifest, environment_names: object, + *, + additional_mounts: Iterable[tuple[Mount, Path]] = (), ) -> None: """Reject ambient credential sources when a helper runs inside the sandbox.""" in_container_mount_paths = tuple( mount_path.as_posix() - for mount, mount_path in manifest.mount_targets() + for mount, mount_path in (*manifest.mount_targets(), *additional_mounts) if mount.mount_strategy.credential_boundary == "inside_sandbox" ) credential_environment_names = _credential_like_environment_names(environment_names) @@ -143,18 +147,21 @@ def validate_mount_credential_environment_names( def validate_mount_credential_boundary_for_session( mount: Mount, + mount_path: Path, manifest: Manifest, environment_names: object, ) -> None: """Apply the shared mount boundary to a direct mount lifecycle call.""" - isolated_manifest = Manifest( - root=manifest.root, - entries={"mount": mount}, - environment=manifest.environment.model_copy(deep=True), + validate_mount_credential_boundaries( + manifest, + additional_mounts=((mount, mount_path),), + ) + validate_mount_credential_environment_names( + manifest, + environment_names, + additional_mounts=((mount, mount_path),), ) - validate_mount_credential_boundaries(isolated_manifest) - validate_mount_credential_environment_names(isolated_manifest, environment_names) def _configured_pattern_credential_fields(pattern: object) -> tuple[str, ...]: @@ -236,12 +243,23 @@ def _raw_mount_uses_in_container_strategy(raw_entry: Mapping[object, object]) -> ) -def validate_mount_credential_boundaries(manifest: Manifest) -> None: +def validate_mount_credential_boundaries( + manifest: Manifest, + *, + additional_mounts: Iterable[tuple[Mount, Path]] = (), +) -> None: """Reject credential-bearing mounts that can expose credentials inside the sandbox.""" - manifest._reject_serialized_files_with_opaque_credential_authorities() - validate_mount_credential_environment_names(manifest, manifest.environment.value) - for mount, mount_path in manifest.mount_targets(): + additional_mounts = tuple(additional_mounts) + manifest._reject_serialized_files_with_opaque_credential_authorities( + additional_mounts=additional_mounts + ) + validate_mount_credential_environment_names( + manifest, + manifest.environment.value, + additional_mounts=additional_mounts, + ) + for mount, mount_path in (*manifest.mount_targets(), *additional_mounts): inline_url_credential_fields = tuple( dict.fromkeys( ( @@ -524,7 +542,7 @@ def sanitize_serialized_mount_credentials( serialized_environment_names: dict[object, object] = {} if isinstance(raw_environment_values, Mapping): serialized_environment_names.update(raw_environment_values) - for field_name in ("base_envs", "base_env_vars", "secret_refs"): + for field_name in ("base_envs", "base_env_vars", "env", "secret_refs"): field_value = sanitized.get(field_name) if isinstance(field_value, Mapping): serialized_environment_names.update(field_value) @@ -536,10 +554,10 @@ def sanitize_serialized_mount_credentials( ) entries = copy.deepcopy(raw_entries) manifest["entries"] = entries - entry_paths = _collect_raw_entry_paths(entries) - has_serialized_files = _raw_entries_contain_serialized_file(entries) raw_root = manifest.get("root", "/workspace") root = Path(raw_root) if isinstance(raw_root, str) else Path("/workspace") + entry_paths = _collect_raw_entry_paths(entries, root=root) + has_serialized_files = _raw_entries_contain_serialized_file(entries) credentials_by_path: dict[str, list[str]] = {} _sanitize_entry_mapping( @@ -742,6 +760,9 @@ def _raw_mount_credential_slots_by_path( entries = manifest.get("entries") if not isinstance(entries, dict): return {} + raw_root = manifest.get("root", "/workspace") + root = Path(raw_root) if isinstance(raw_root, str) else Path("/workspace") + _collect_raw_entry_paths(entries, root=root) registered_types = BaseEntry.registered_types() slots_by_path: dict[str, frozenset[str]] = {} stack: list[tuple[str, dict[Any, Any]]] = [("", entries)] @@ -751,7 +772,8 @@ def _raw_mount_credential_slots_by_path( if not isinstance(raw_entry, dict): continue name = str(raw_name) - entry_path = f"{prefix}/{name}" if prefix else name + raw_entry_path = f"{prefix}/{name}" if prefix else name + entry_path = _canonical_raw_entry_path(root, raw_entry_path) entry_type = raw_entry.get("type") entry_class = registered_types.get(entry_type) if isinstance(entry_type, str) else None mount_class = ( @@ -832,6 +854,72 @@ def rebind_manifest_mount_credentials( return rebound +def build_processed_resume_credential_authority( + persisted_manifest: Manifest, + processed_manifest: Manifest, + trusted_manifest: Manifest | None, + credentials_by_path: dict[str, tuple[str, ...]], + *, + allow_runtime_root_mismatch: bool = False, +) -> Manifest: + """Combine capability-produced credentials with matching trusted mount credentials.""" + + persisted_mounts = _mounts_by_entry_path(persisted_manifest) + processed_mounts = _mounts_by_entry_path(processed_manifest) + trusted_mounts = _mounts_by_entry_path(trusted_manifest) if trusted_manifest is not None else {} + authority = processed_manifest.model_copy(deep=True) + authority_mounts = _mounts_by_entry_path(authority) + + if ( + trusted_manifest is not None + and persisted_manifest.root != trusted_manifest.root + and not allow_runtime_root_mismatch + ): + raise ValueError( + "Sandbox session state manifest root does not match current trusted configuration" + ) + if set(persisted_mounts) != set(processed_mounts): + raise ValueError( + "Sandbox session state mount topology does not match current trusted configuration" + ) + for path, persisted_mount in persisted_mounts.items(): + if path not in trusted_mounts and path not in credentials_by_path: + raise ValueError( + "Sandbox session state mount topology does not match current trusted configuration" + ) + if _without_credentials(persisted_mount) != _without_credentials(processed_mounts[path]): + raise ValueError( + "Sandbox session state mount configuration does not match current trusted " + f"configuration for path: {path}" + ) + + for path, field_names in credentials_by_path.items(): + authority_mount = authority_mounts.get(path) + if authority_mount is None: + raise ValueError( + "Sandbox session state mount topology does not match current trusted configuration" + ) + configured_slots = set( + _canonical_credential_slots(authority_mount._configured_credential_fields()) + ) + missing_fields = tuple( + field_name for field_name in field_names if field_name not in configured_slots + ) + if not missing_fields: + continue + trusted_mount = trusted_mounts.get(path) + if trusted_mount is None or _without_credentials(authority_mount) != _without_credentials( + trusted_mount + ): + raise ValueError( + "Sandbox session state mount configuration does not match current trusted " + f"configuration for path: {path}" + ) + authority_mount._restore_configured_credentials(trusted_mount, missing_fields) + + return authority + + def _sanitize_entry_mapping( entries: dict[Any, Any], *, @@ -865,7 +953,8 @@ def _sanitize_entry_mapping( if not isinstance(raw_entry, dict): raise ValueError("Persisted sandbox manifest entries must contain objects") name = str(raw_name) - entry_path = f"{prefix}/{name}" if prefix else name + raw_entry_path = f"{prefix}/{name}" if prefix else name + entry_path = _canonical_raw_entry_path(root, raw_entry_path) entry_type = raw_entry.get("type") entry_class = registered_types.get(entry_type) if isinstance(entry_type, str) else None mount_class: type[Mount] | None = ( @@ -1332,6 +1421,7 @@ def _raw_entries_contain_serialized_file(entries: dict[Any, Any]) -> bool: def _collect_raw_entry_paths( entries: dict[Any, Any], *, + root: Path, prefix: str = "", ) -> frozenset[str]: paths: set[str] = set() @@ -1339,16 +1429,35 @@ def _collect_raw_entry_paths( if not isinstance(raw_entry, dict): raise ValueError("Persisted sandbox manifest entries must contain objects") name = str(raw_name) - entry_path = f"{prefix}/{name}" if prefix else name + raw_entry_path = f"{prefix}/{name}" if prefix else name + entry_path = _canonical_raw_entry_path(root, raw_entry_path) + if entry_path in paths: + raise ValueError("Persisted sandbox manifest entry paths collide after normalization") paths.add(entry_path) children = raw_entry.get("children") if "children" in raw_entry and not isinstance(children, dict): raise ValueError("Persisted sandbox entry children must be an object") if isinstance(children, dict): - paths.update(_collect_raw_entry_paths(children, prefix=entry_path)) + child_paths = _collect_raw_entry_paths(children, root=root, prefix=entry_path) + if paths & child_paths: + raise ValueError( + "Persisted sandbox manifest entry paths collide after normalization" + ) + paths.update(child_paths) return frozenset(paths) +def _canonical_raw_entry_path(root: Path, raw_entry_path: str) -> str: + try: + candidate = root / posix_path_as_path(coerce_posix_path(raw_entry_path)) + normalized = Manifest._normalize_in_workspace_path(root, candidate) + if normalized is None: + raise ValueError + return normalized.relative_to(root).as_posix() + except (InvalidManifestPathError, TypeError, ValueError): + raise ValueError("Persisted sandbox manifest entry path is invalid") from None + + def _references_raw_manifest_entry( config_file_path: str, *, diff --git a/src/agents/sandbox/entries/mounts/base.py b/src/agents/sandbox/entries/mounts/base.py index 07b870be12..56d4c795a1 100644 --- a/src/agents/sandbox/entries/mounts/base.py +++ b/src/agents/sandbox/entries/mounts/base.py @@ -540,7 +540,7 @@ def _field_contains_inline_url_credentials( def _configured_option_map_credential_fields( - options: Mapping[object, object], + options: Mapping[Any, Any], *, field_prefix: str, ) -> tuple[str, ...]: @@ -1368,7 +1368,7 @@ async def apply( intentionally no-ops because the backend attaches them before the session starts. """ - self._validate_credential_boundary_for_session(session) + self._validate_credential_boundary_for_session(session, dest) return await self.mount_strategy.activate(self, session, dest, base_dir) @_data_redacted_async_boundary @@ -1380,14 +1380,19 @@ async def unmount( ) -> None: """Deactivate this mount for manifest teardown.""" - self._validate_credential_boundary_for_session(session) + self._validate_credential_boundary_for_session(session, dest) await self.mount_strategy.deactivate(self, session, dest, base_dir) - def _validate_credential_boundary_for_session(self, session: BaseSandboxSession) -> None: + def _validate_credential_boundary_for_session( + self, + session: BaseSandboxSession, + mount_path: Path, + ) -> None: from ..._mount_security import validate_mount_credential_boundary_for_session validate_mount_credential_boundary_for_session( self, + mount_path, session.state.manifest, session.state._mount_credential_environment_names(), ) diff --git a/src/agents/sandbox/manifest.py b/src/agents/sandbox/manifest.py index 25f7185b59..ab2db2e5f5 100644 --- a/src/agents/sandbox/manifest.py +++ b/src/agents/sandbox/manifest.py @@ -1,6 +1,6 @@ import abc import inspect -from collections.abc import Iterator, Mapping +from collections.abc import Iterable, Iterator, Mapping from pathlib import Path, PurePath, PurePosixPath from typing import Any, ClassVar, Literal @@ -8,6 +8,7 @@ BaseModel, Field, SerializeAsAny, + ValidationError, field_serializer, field_validator, ) @@ -272,10 +273,14 @@ def mount_targets(self) -> list[tuple[Mount, Path]]: def ephemeral_mount_targets(self) -> list[tuple[Mount, Path]]: return [(artifact, path) for artifact, path in self.mount_targets() if artifact.ephemeral] - def _reject_serialized_files_with_opaque_credential_authorities(self) -> None: + def _reject_serialized_files_with_opaque_credential_authorities( + self, + *, + additional_mounts: Iterable[tuple[Mount, Path]] = (), + ) -> None: if not any(isinstance(entry, File) for _path, entry in self.iter_entries()): return - for mount, mount_path in self.mount_targets(): + for mount, mount_path in (*self.mount_targets(), *additional_mounts): if not mount._configured_opaque_credential_authority_options(): continue raise MountConfigError( @@ -442,4 +447,7 @@ def _coerce_manifest(value: Manifest | dict[str, Any], *, parameter_name: str) - f"{parameter_name}.extra_path_grants must be configured on a trusted " "Manifest instance, not in a dictionary" ) - return coerce_pydantic_config(value, Manifest, parameter_name=parameter_name) + try: + return coerce_pydantic_config(value, Manifest, parameter_name=parameter_name) + except ValidationError: + raise TypeError(f"{parameter_name} is invalid") from None diff --git a/src/agents/sandbox/runtime_session_manager.py b/src/agents/sandbox/runtime_session_manager.py index 6323aaf708..2c28380d1a 100644 --- a/src/agents/sandbox/runtime_session_manager.py +++ b/src/agents/sandbox/runtime_session_manager.py @@ -19,6 +19,7 @@ ) from ..tracing import custom_span, get_current_trace from ._mount_security import ( + build_processed_resume_credential_authority, validate_mount_credential_boundaries, validate_mount_credential_environment_names, ) @@ -792,23 +793,36 @@ def _process_resumed_state_manifest( trusted_manifest: Manifest | None, ) -> SandboxSessionState: if session_state.mount_credentials_require_rebind: - trusted_resume_manifest = cls._process_manifest( + resume_manifest = session_state.manifest + if session_state.path_grants_require_rebind and trusted_manifest is not None: + resume_manifest = resume_manifest.model_copy( + update={ + "extra_path_grants": tuple( + grant.model_copy() for grant in trusted_manifest.extra_path_grants + ) + }, + ) + processed_resume_manifest = cls._process_manifest( capabilities, - trusted_manifest, + resume_manifest, run_as_user=cls._agent_run_as_user(agent), ) - rebound_state = session_state.rebind_persisted_mount_credentials( - trusted_resume_manifest + if processed_resume_manifest is None: + raise ValueError("Sandbox session state manifest is required for credential rebind") + credential_authority = build_processed_resume_credential_authority( + session_state.manifest, + processed_resume_manifest, + trusted_manifest, + session_state._mount_credentials_require_rebind, + allow_runtime_root_mismatch=( + getattr(session_state, "workspace_root_owned", False) is True + ), ) - if trusted_resume_manifest is None: - return rebound_state - processed_manifest = cls._manifest_with_run_as_user( - rebound_state.manifest, - cls._agent_run_as_user(agent), + processed_state = session_state.model_copy( + update={"manifest": processed_resume_manifest} ) - if processed_manifest is not rebound_state.manifest: - rebound_state = rebound_state.model_copy(update={"manifest": processed_manifest}) - return rebound_state.rebind_persisted_path_grants(trusted_resume_manifest) + rebound_state = processed_state.rebind_persisted_mount_credentials(credential_authority) + return rebound_state.rebind_persisted_path_grants(processed_resume_manifest) rebound_state = session_state resume_manifest = rebound_state.manifest diff --git a/src/agents/sandbox/session/sandbox_session_state.py b/src/agents/sandbox/session/sandbox_session_state.py index fc624f29e1..b2cb745aca 100644 --- a/src/agents/sandbox/session/sandbox_session_state.py +++ b/src/agents/sandbox/session/sandbox_session_state.py @@ -1,8 +1,9 @@ from __future__ import annotations +import traceback import uuid from collections.abc import Iterable -from typing import Any, ClassVar, Literal, get_args, get_origin +from typing import Any, ClassVar, Literal, NoReturn, get_args, get_origin from pydantic import ( BaseModel, @@ -26,6 +27,11 @@ from ..manifest import Manifest from ..snapshot import SnapshotBase + +def _raise_value_free_state_serialization_error() -> NoReturn: + raise ValueError("Sandbox session state contains unsupported mount credential data") from None + + SessionStateClass = type["SandboxSessionState"] @@ -56,7 +62,7 @@ def mount_credentials_require_rebind(self) -> tuple[str, ...]: def _mount_credential_environment_names(self) -> tuple[str, ...]: names: set[str] = set() - for field_name in ("base_envs", "base_env_vars", "secret_refs"): + for field_name in ("base_envs", "base_env_vars", "env", "secret_refs"): value = getattr(self, field_name, None) if isinstance(value, dict): names.update(name for name in value if isinstance(name, str)) @@ -265,17 +271,36 @@ def assert_trusted_manifest_rebound(self) -> None: @model_serializer(mode="wrap") def _serialize_always_include_defaults(self, handler: Any) -> dict[str, Any]: - data: dict[str, Any] = handler(self) - if self.type: - data["type"] = self.type - if self.session_id: - data["session_id"] = self.session_id - if self.mount_credentials_require_rebind: - data[REDACTED_MOUNT_CREDENTIAL_PATHS_KEY] = { - path: list(field_names) - for path, field_names in self._mount_credentials_require_rebind.items() - } - return sanitize_serialized_session_state_mount_credentials(data) + data: dict[str, Any] | None = None + serialization_failed = False + try: + data = handler(self) + if not isinstance(data, dict): + raise TypeError("Sandbox session state serializer must return an object") + if self.type: + data["type"] = self.type + if self.session_id: + data["session_id"] = self.session_id + if self.mount_credentials_require_rebind: + data[REDACTED_MOUNT_CREDENTIAL_PATHS_KEY] = { + path: list(field_names) + for path, field_names in self._mount_credentials_require_rebind.items() + } + return sanitize_serialized_session_state_mount_credentials(data) + except Exception as error: + if error.__traceback__ is not None: + traceback.clear_frames(error.__traceback__) + error.__traceback__ = None + error.__cause__ = None + error.__context__ = None + if data is not None: + data.clear() + serialization_failed = True + del error + if serialization_failed: + del data, handler, self + _raise_value_free_state_serialization_error() + raise AssertionError("unreachable") @field_validator("snapshot", mode="before") @classmethod diff --git a/tests/extensions/sandbox/test_blaxel_mounts.py b/tests/extensions/sandbox/test_blaxel_mounts.py index 62194fe874..1f2d4d10da 100644 --- a/tests/extensions/sandbox/test_blaxel_mounts.py +++ b/tests/extensions/sandbox/test_blaxel_mounts.py @@ -19,6 +19,7 @@ ) from agents.sandbox.entries import S3Mount from agents.sandbox.errors import MountCommandError, MountConfigError +from agents.sandbox.manifest import Manifest _INJECTION = "x; touch /tmp/pwned" @@ -49,7 +50,8 @@ def __init__(self) -> None: self.commands: list[str] = [] self.state = SimpleNamespace( session_id=uuid.uuid4(), - manifest=SimpleNamespace(root="/workspace"), + manifest=Manifest(), + _mount_credential_environment_names=lambda: (), ) self.writes: dict[str, bytes] = {} self.persist_skip_paths: list[Path] = [] diff --git a/tests/extensions/sandbox/test_vercel.py b/tests/extensions/sandbox/test_vercel.py index baacff1610..47a92ecf93 100644 --- a/tests/extensions/sandbox/test_vercel.py +++ b/tests/extensions/sandbox/test_vercel.py @@ -575,6 +575,25 @@ async def test_vercel_create_rejects_s3_credentials_before_provider_call( assert _FakeAsyncSandbox.create_calls == [] +@pytest.mark.asyncio +async def test_vercel_create_rejects_ambient_s3_credentials_before_provider_call( + monkeypatch: pytest.MonkeyPatch, +) -> None: + vercel_module = _load_vercel_module(monkeypatch) + package_module = importlib.import_module("agents.extensions.sandbox.vercel") + client = vercel_module.VercelSandboxClient() + + with pytest.raises(MountConfigError, match="credential-like environment variables"): + await client.create( + manifest=_vercel_s3_manifest(package_module), + options=vercel_module.VercelSandboxClientOptions( + env={"AWS_SECRET_ACCESS_KEY": "secret"}, + ), + ) + + assert _FakeAsyncSandbox.create_calls == [] + + def test_vercel_from_state_rejects_trusted_s3_credentials( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -601,6 +620,29 @@ def test_vercel_from_state_rejects_trusted_s3_credentials( ) +def test_vercel_from_state_rejects_ambient_s3_credentials( + monkeypatch: pytest.MonkeyPatch, +) -> None: + vercel_module = _load_vercel_module(monkeypatch) + package_module = importlib.import_module("agents.extensions.sandbox.vercel") + manifest = _vercel_s3_manifest(package_module) + state = vercel_module.VercelSandboxSessionState( + session_id="00000000-0000-0000-0000-000000000203", + manifest=manifest, + snapshot=NoopSnapshot(id="snapshot"), + sandbox_id="sandbox-with-ambient-credentials", + env={"AWS_SECRET_ACCESS_KEY": "secret"}, + ) + + with pytest.raises(MountConfigError, match="credential-like environment variables"): + vercel_module.VercelSandboxSession.from_state( + state, + trusted_s3_mounts={ + "/workspace/remote": cast(S3Mount, manifest.entries["remote"]), + }, + ) + + @pytest.mark.asyncio async def test_vercel_create_revalidates_mutated_s3_mount( monkeypatch: pytest.MonkeyPatch, diff --git a/tests/sandbox/test_mounts.py b/tests/sandbox/test_mounts.py index aa63d5560b..0bb24ded9d 100644 --- a/tests/sandbox/test_mounts.py +++ b/tests/sandbox/test_mounts.py @@ -11,6 +11,7 @@ AzureBlobMount, BoxMount, DockerVolumeMountStrategy, + File, FuseMountPattern, GCSMount, InContainerMountStrategy, @@ -1375,6 +1376,52 @@ async def test_rclone_mount_rejects_credentials_before_public_unmount_side_effec assert session.exec_calls == [] +@pytest.mark.asyncio +async def test_public_mount_rejects_session_manifest_credential_file_before_side_effects() -> None: + mount = GCSMount( + bucket="bucket", + service_account_file="credentials.json", + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ) + session = _GeneratedConfigApplySession( + session_id=uuid.UUID("12345678-1234-5678-1234-567812345678"), + ) + session.state.manifest.entries["credentials.json"] = File(content=b"credential") + + with pytest.raises(MountConfigError, match="serialized manifest"): + await mount.apply(session, Path("/workspace/remote"), Path.cwd()) + + assert session.exec_calls == [] + assert session.write_calls == [] + + +@pytest.mark.parametrize("operation", ["apply", "unmount"]) +@pytest.mark.asyncio +async def test_public_mount_rejects_opaque_credential_authority_with_manifest_file( + operation: str, +) -> None: + mount = S3Mount( + bucket="bucket", + mount_strategy=DockerVolumeMountStrategy( + driver="rclone", + driver_options={"password-command": "cat -- credentials.json"}, + ), + ) + session = _GeneratedConfigApplySession( + session_id=uuid.UUID("12345678-1234-5678-1234-567812345678"), + ) + session.state.manifest.entries["credentials.json"] = File(content=b"credential") + + with pytest.raises(MountConfigError, match="opaque mount credential commands"): + if operation == "apply": + await mount.apply(session, Path("/workspace/remote"), Path.cwd()) + else: + await mount.unmount(session, Path("/workspace/remote"), Path.cwd()) + + assert session.exec_calls == [] + assert session.write_calls == [] + + @pytest.mark.parametrize( "mount", [ diff --git a/tests/sandbox/test_runtime.py b/tests/sandbox/test_runtime.py index 27e0fa0a24..134077bf08 100644 --- a/tests/sandbox/test_runtime.py +++ b/tests/sandbox/test_runtime.py @@ -104,7 +104,7 @@ from tests.utils.simple_session import SimpleListSession -class _CredentialEnvironmentTestState(TestSessionState): +class _CredentialEnvironmentTestState(SandboxSessionState): __test__ = False type: Literal["credential-environment-test"] = "credential-environment-test" base_envs: dict[str, str] @@ -1266,7 +1266,7 @@ async def test_runner_adds_remote_mount_policy_instructions() -> None: entries={ "remote": S3Mount( bucket="bucket", - mount_strategy=InContainerMountStrategy(pattern=MountpointMountPattern()), + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), ) } ) @@ -2268,7 +2268,7 @@ async def test_unix_local_client_delete_unmounts_workspace_mounts_before_rmtree( entries={ "remote": S3Mount( bucket="bucket", - mount_strategy=InContainerMountStrategy(pattern=MountpointMountPattern()), + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), ), } ) @@ -2309,11 +2309,11 @@ async def test_unix_local_client_delete_unmounts_nested_mounts_deepest_first( entries={ "outer": S3Mount( bucket="bucket", - mount_strategy=InContainerMountStrategy(pattern=MountpointMountPattern()), + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), ), "outer/child": S3Mount( bucket="bucket", - mount_strategy=InContainerMountStrategy(pattern=MountpointMountPattern()), + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), ), } ) @@ -2349,7 +2349,7 @@ async def test_unix_local_client_delete_skips_rmtree_when_unmount_fails( entries={ "SECRET_REMOTE_MOUNT": S3Mount( bucket="bucket", - mount_strategy=InContainerMountStrategy(pattern=MountpointMountPattern()), + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), ), } ) @@ -3633,6 +3633,52 @@ def test_session_manager_rebinds_mount_credentials_from_current_trusted_manifest processed.assert_trusted_manifest_rebound() +def test_session_manager_rebinds_mount_credentials_and_host_path_grants( + tmp_path: Path, +) -> None: + trusted_manifest = Manifest( + entries={ + "remote": S3Mount( + bucket="example", + access_key_id="access-key-sentinel", + secret_access_key="secret-key-sentinel", + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ) + }, + extra_path_grants=( + SandboxPathGrant( + path="/mnt/shared-data", + host_path=str(tmp_path), + read_only=True, + ), + ), + ) + client = _FakeClient(_FakeSession(Manifest())) + payload = client.serialize_session_state( + TestSessionState( + manifest=trusted_manifest, + snapshot=NoopSnapshot(id="resume"), + ) + ) + state = client._deserialize_session_state_payload(payload, TestSessionState) + agent = SandboxAgent(name="worker", model=FakeModel(), instructions="Worker.") + + processed = SandboxRuntimeSessionManager._process_resumed_state_manifest( + agent=agent, + capabilities=[], + session_state=state, + trusted_manifest=trusted_manifest, + ) + + mount = processed.manifest.entries["remote"] + assert isinstance(mount, S3Mount) + assert mount.secret_access_key == "secret-key-sentinel" + assert processed.manifest.extra_path_grants == trusted_manifest.extra_path_grants + assert processed.mount_credentials_require_rebind == () + assert processed.path_grants_require_rebind == () + processed.assert_trusted_manifest_rebound() + + def test_session_manager_preserves_persisted_non_mount_entries_during_credential_rebind() -> None: persisted_manifest = Manifest( entries={ @@ -3670,6 +3716,51 @@ def test_session_manager_preserves_persisted_non_mount_entries_during_credential processed.assert_trusted_manifest_rebound() +def test_session_manager_applies_capability_manifest_changes_during_credential_rebind() -> None: + persisted_manifest = Manifest( + entries={ + "cap.txt": File(content=b"old"), + "remote": S3Mount( + bucket="example", + access_key_id="access-key-sentinel", + secret_access_key="secret-key-sentinel", + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ), + } + ) + trusted_manifest = Manifest( + entries={ + "remote": S3Mount( + bucket="example", + access_key_id="access-key-sentinel", + secret_access_key="secret-key-sentinel", + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ) + } + ) + client = _FakeClient(_FakeSession(Manifest())) + payload = client.serialize_session_state( + TestSessionState( + manifest=persisted_manifest, + snapshot=NoopSnapshot(id="resume"), + ) + ) + state = client._deserialize_session_state_payload(payload, TestSessionState) + capability = _ManifestMutationCapability(content=b"new") + agent = SandboxAgent(name="worker", model=FakeModel(), instructions="Worker.") + + processed = SandboxRuntimeSessionManager._process_resumed_state_manifest( + agent=agent, + capabilities=[capability], + session_state=state, + trusted_manifest=trusted_manifest, + ) + + assert capability.process_calls == 1 + assert processed.manifest.entries["cap.txt"] == File(content=b"new") + processed.assert_trusted_manifest_rebound() + + def test_session_manager_rejects_persisted_root_before_mount_credential_rebind() -> None: trusted_manifest = Manifest( root="/workspace", diff --git a/tests/sandbox/test_session_state_roundtrip.py b/tests/sandbox/test_session_state_roundtrip.py index a0d5cc01f2..877ee0ea11 100644 --- a/tests/sandbox/test_session_state_roundtrip.py +++ b/tests/sandbox/test_session_state_roundtrip.py @@ -149,6 +149,19 @@ async def restorable(self, *, dependencies: Dependencies | None = None) -> bool: return False +class _FailingFieldSerializerSessionState(SandboxSessionState): + type: Literal["failing-field-serializer-roundtrip"] = "failing-field-serializer-roundtrip" + token: str = "token" + + @field_serializer("token") + def _serialize_token(self, value: str) -> str: + _ = value + mount = next( + entry for _path, entry in self.manifest.iter_entries() if isinstance(entry, S3Mount) + ) + raise RuntimeError(mount.secret_access_key) + + # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- @@ -498,6 +511,68 @@ def test_direct_state_serialization_redacts_mount_credentials_and_preserves_rebi assert isinstance(rebound_mount, S3Mount) assert rebound_mount.secret_access_key == "direct-secret-key-sentinel" + def test_direct_state_serialization_uses_canonical_mount_marker_paths(self) -> None: + client = _RoundTripClient() + trusted_manifest = Manifest( + entries={ + "nested\\remote": S3Mount( + bucket="example", + access_key_id="canonical-access-key-sentinel", + secret_access_key="canonical-secret-key-sentinel", + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ) + } + ) + state = _SimpleSessionState( + manifest=trusted_manifest, + snapshot=NoopSnapshot(id="snapshot"), + ) + + payload = client.serialize_session_state(state) + + assert payload["__openai_agents_redacted_mount_credential_paths"] == { + "nested/remote": ["access_key_id", "secret_access_key"] + } + restored = client.deserialize_session_state(payload) + rebound = restored.rebind_persisted_mount_credentials(trusted_manifest) + rebound_mount = next( + entry for _path, entry in rebound.manifest.iter_entries() if isinstance(entry, S3Mount) + ) + assert rebound_mount.secret_access_key == "canonical-secret-key-sentinel" + rebound.assert_trusted_manifest_rebound() + + @pytest.mark.parametrize("mode", ["python", "json"]) + def test_extension_field_serializer_cannot_expose_mount_credentials(self, mode: str) -> None: + sentinel = "FIELD_SERIALIZER_SECRET_SENTINEL" + state = _FailingFieldSerializerSessionState( + manifest=Manifest( + entries={ + "remote": S3Mount( + bucket="example", + secret_access_key=sentinel, + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ) + } + ), + snapshot=NoopSnapshot(id="snapshot"), + ) + + with pytest.raises(ValueError, match="unsupported mount credential data") as exc_info: + if mode == "python": + state.model_dump(mode="json") + else: + state.model_dump_json() + + error: BaseException | None = exc_info.value + while error is not None: + assert sentinel not in repr(error) + traceback = error.__traceback__ + while traceback is not None: + if "/src/agents/" in Path(traceback.tb_frame.f_code.co_filename).as_posix(): + assert sentinel not in repr(traceback.tb_frame.f_locals) + traceback = traceback.tb_next + error = error.__cause__ or error.__context__ + def test_client_serialization_redacts_docker_driver_credentials_and_rebinds_them( self, ) -> None: @@ -769,10 +844,25 @@ def test_direct_state_serialization_rejects_driver_credential_file( snapshot=NoopSnapshot(id="snapshot"), ) - with pytest.raises(ValueError, match="must not be a manifest entry") as exc_info: + with pytest.raises(ValueError, match="unsupported mount credential data") as exc_info: state.model_dump(mode="json") assert sentinel not in repr(exc_info.value) + error: BaseException | None = exc_info.value + while error is not None: + assert sentinel not in repr(error) + traceback = error.__traceback__ + while traceback is not None: + if "/src/agents/" in Path(traceback.tb_frame.f_code.co_filename).as_posix(): + assert sentinel not in repr(traceback.tb_frame.f_locals) + traceback = traceback.tb_next + error = error.__cause__ or error.__context__ + + with pytest.raises(ValueError) as json_exc_info: + state.model_dump_json() + assert sentinel not in repr(json_exc_info.value) + assert json_exc_info.value.__cause__ is None + assert json_exc_info.value.__context__ is None def test_client_serialization_redacts_azure_driver_credentials(self) -> None: client = _RoundTripClient() @@ -1212,6 +1302,64 @@ def test_raw_state_rejects_manifest_owned_provider_credential_files( assert sentinel not in repr(parse_error.value) assert sentinel not in repr(client_error.value) + @pytest.mark.parametrize( + ("entry_path", "credential_path"), + [ + ("credentials\\gcs.json", "credentials/gcs.json"), + ("nested/../credentials.json", "credentials.json"), + ], + ids=["backslash", "dot-segment"], + ) + def test_raw_state_rejects_canonical_manifest_credential_file_aliases( + self, + entry_path: str, + credential_path: str, + ) -> None: + client = _RoundTripClient() + sentinel = "ALIASED_PROVIDER_FILE_SECRET_SENTINEL" + state = _SimpleSessionState( + manifest=Manifest(), + snapshot=NoopSnapshot(id="snapshot"), + ) + payload = cast(dict[str, object], state.model_dump(mode="json")) + payload["manifest"] = { + "root": "/workspace", + "entries": { + entry_path: { + "type": "file", + "content": sentinel, + }, + "remote": GCSMount( + bucket="example", + service_account_file=credential_path, + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ).model_dump(mode="json"), + }, + } + + with pytest.raises(ValueError, match="must not be a manifest entry") as exc_info: + client.deserialize_session_state(payload) + + assert sentinel not in repr(exc_info.value) + + def test_raw_state_rejects_colliding_canonical_manifest_paths(self) -> None: + client = _RoundTripClient() + state = _SimpleSessionState( + manifest=Manifest(), + snapshot=NoopSnapshot(id="snapshot"), + ) + payload = cast(dict[str, object], state.model_dump(mode="json")) + payload["manifest"] = { + "root": "/workspace", + "entries": { + "credentials.json": {"type": "file", "content": "first"}, + "nested/../credentials.json": {"type": "file", "content": "second"}, + }, + } + + with pytest.raises(ValueError, match="collide after normalization"): + client.deserialize_session_state(payload) + @pytest.mark.parametrize( "marker", [ diff --git a/tests/test_run_state.py b/tests/test_run_state.py index de7fafdff8..fc8aad9f50 100644 --- a/tests/test_run_state.py +++ b/tests/test_run_state.py @@ -7037,6 +7037,68 @@ async def main(): assert completed.returncode == 0, completed.stderr + def test_run_state_rejects_vercel_ambient_credentials_before_extension_import(self) -> None: + agent = Agent(name="TestAgent") + state: RunState[Any, Agent[Any]] = make_state( + agent, + context=RunContextWrapper(context={}), + original_input="test", + ) + sentinel = "RUN_STATE_VERCEL_AMBIENT_CREDENTIAL_SENTINEL" + payload = state.to_json() + payload["$schemaVersion"] = "1.14" + payload["sandbox"] = { + "backend_id": "vercel", + "session_state": { + "type": "vercel", + "manifest": { + "entries": { + "remote": { + "type": "s3_mount", + "bucket": "example", + "mount_strategy": {"type": "vercel_cloud_bucket"}, + } + } + }, + "snapshot": {"type": "noop", "id": "snapshot"}, + "env": {"AWS_SECRET_ACCESS_KEY": sentinel}, + }, + } + script = ( + textwrap.dedent( + """ + import asyncio + import json + + from agents import Agent + from agents.run_state import RunState + from agents.sandbox.entries.mounts.base import MountStrategyBase + + assert "vercel_cloud_bucket" not in MountStrategyBase._subclass_registry + payload = json.loads(__PAYLOAD_JSON__) + + try: + asyncio.run(RunState.from_json(Agent(name="TestAgent"), payload)) + except ValueError as error: + assert __SENTINEL__ not in repr(error) + else: + raise AssertionError("credential-bearing Vercel state was accepted") + """ + ) + .replace("__PAYLOAD_JSON__", repr(json.dumps(payload))) + .replace("__SENTINEL__", repr(sentinel)) + ) + + completed = subprocess.run( + [sys.executable, "-c", script], + check=False, + capture_output=True, + text=True, + timeout=30, + ) + + assert completed.returncode == 0, completed.stderr + def test_run_state_preserves_released_extension_mounts_before_extension_import(self) -> None: from agents.extensions.sandbox.blaxel.mounts import ( BlaxelDriveMount, From fdd7fa31d4ca5a7607313ebd602cdd72db8a8e7e Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 7 Aug 2026 18:03:43 +0900 Subject: [PATCH 4/4] fix(sandbox): enforce mount credential boundaries --- examples/run_examples.py | 1 - .../mounts/s3_files_mount_read_write.py | 72 --------- .../extensions/sandbox/blaxel/mounts.py | 3 + .../extensions/sandbox/blaxel/sandbox.py | 2 + .../extensions/sandbox/daytona/mounts.py | 5 + src/agents/extensions/sandbox/e2b/mounts.py | 5 + .../extensions/sandbox/runloop/mounts.py | 5 + .../extensions/sandbox/runloop/sandbox.py | 2 + .../extensions/sandbox/vercel/mounts.py | 5 + .../extensions/sandbox/vercel/sandbox.py | 4 + src/agents/sandbox/_mount_security.py | 17 +++ src/agents/sandbox/entries/mounts/base.py | 44 +++++- src/agents/sandbox/entries/mounts/patterns.py | 49 ++----- src/agents/sandbox/sandboxes/docker.py | 4 +- src/agents/sandbox/sandboxes/unix_local.py | 6 +- .../sandbox/session/base_sandbox_session.py | 13 ++ .../sandbox/session/manifest_application.py | 2 + tests/extensions/sandbox/test_blaxel.py | 31 +++- tests/extensions/sandbox/test_daytona.py | 15 +- tests/extensions/sandbox/test_e2b.py | 18 +-- tests/extensions/sandbox/test_modal.py | 24 +-- tests/extensions/sandbox/test_runloop.py | 33 +++++ tests/extensions/sandbox/test_vercel.py | 28 ++++ tests/sandbox/test_docker.py | 29 ++++ tests/sandbox/test_mount_security.py | 33 ++++- tests/sandbox/test_mounts.py | 137 ++++++++++++------ tests/sandbox/test_unix_local.py | 16 ++ tests/test_run_examples_script.py | 1 - 28 files changed, 404 insertions(+), 200 deletions(-) delete mode 100644 examples/sandbox/docker/mounts/s3_files_mount_read_write.py diff --git a/examples/run_examples.py b/examples/run_examples.py index 8d9a7f1b3a..f1277ba12f 100644 --- a/examples/run_examples.py +++ b/examples/run_examples.py @@ -77,7 +77,6 @@ "examples/sandbox/misc/reference_policy_mcp_server.py", "examples/sandbox/docker/mounts/azure_mount_read_write.py", "examples/sandbox/docker/mounts/gcs_mount_read_write.py", - "examples/sandbox/docker/mounts/s3_files_mount_read_write.py", "examples/sandbox/docker/mounts/s3_mount_read_write.py", "examples/sandbox/extensions/daytona/usaspending_text2sql/setup_db.py", "examples/sandbox/extensions/temporal/temporal_sandbox_agent.py", diff --git a/examples/sandbox/docker/mounts/s3_files_mount_read_write.py b/examples/sandbox/docker/mounts/s3_files_mount_read_write.py deleted file mode 100644 index bfda18087f..0000000000 --- a/examples/sandbox/docker/mounts/s3_files_mount_read_write.py +++ /dev/null @@ -1,72 +0,0 @@ -"""Smoke-test an Amazon S3 Files file-system mount in Docker. - -Required: - - S3_FILES_FILE_SYSTEM_ID=fs-... - -Common optional settings: - - S3_FILES_MOUNT_TARGET_IP=10.0.0.123 - AWS_REGION=us-east-1 - S3_FILES_ACCESS_POINT=fsap-... - S3_FILES_SUBPATH=/path/in/file-system - -Example: - - S3_FILES_FILE_SYSTEM_ID=fs-... \ - S3_FILES_MOUNT_TARGET_IP=10.0.0.123 \ - AWS_REGION=us-east-1 \ - uv run python examples/sandbox/docker/mounts/s3_files_mount_read_write.py -""" - -from __future__ import annotations - -import asyncio -import os -import sys -from pathlib import Path - -if __package__ is None or __package__ == "": - sys.path.insert(0, str(Path(__file__).resolve().parents[4])) - -from agents.sandbox.entries import ( - InContainerMountStrategy, - S3FilesMount, - S3FilesMountPattern, -) -from examples.sandbox.docker.mounts.mount_smoke import ( - MountSmokeCase, - require_env, - run_mount_smoke_test, -) - - -def _mount_cases() -> list[MountSmokeCase]: - file_system_id = require_env("S3_FILES_FILE_SYSTEM_ID") - return [ - MountSmokeCase( - name="in_container/s3files", - mount_dir="s3-files-in-container", - mount=S3FilesMount( - file_system_id=file_system_id, - subpath=os.getenv("S3_FILES_SUBPATH"), - mount_target_ip=os.getenv("S3_FILES_MOUNT_TARGET_IP"), - access_point=os.getenv("S3_FILES_ACCESS_POINT"), - region=os.getenv("AWS_REGION") or os.getenv("AWS_DEFAULT_REGION"), - mount_strategy=InContainerMountStrategy(pattern=S3FilesMountPattern()), - read_only=False, - ), - ) - ] - - -async def main() -> None: - await run_mount_smoke_test( - provider="s3-files", - agent_name="S3 Files Mount Smoke Test", - mount_cases=_mount_cases(), - ) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/src/agents/extensions/sandbox/blaxel/mounts.py b/src/agents/extensions/sandbox/blaxel/mounts.py index fcf0798a01..5ae07540e3 100644 --- a/src/agents/extensions/sandbox/blaxel/mounts.py +++ b/src/agents/extensions/sandbox/blaxel/mounts.py @@ -90,6 +90,7 @@ async def activate( dest: Path, base_dir: Path, ) -> list[MaterializedFile]: + self._validate_credential_boundary_for_session(mount, session, dest) _assert_blaxel_session(session) _ = base_dir mount_path = mount._resolve_mount_path(session, dest) @@ -119,12 +120,14 @@ async def teardown_for_snapshot( _ = mount await _unmount_bucket(session, sandbox_path_str(path)) + @_data_redacted_async_boundary async def restore_after_snapshot( self, mount: Mount, session: BaseSandboxSession, path: Path, ) -> None: + self._validate_credential_boundary_for_session(mount, session, path) _assert_blaxel_session(session) config = _build_mount_config(mount, mount_path=sandbox_path_str(path)) await _mount_bucket(session, config) diff --git a/src/agents/extensions/sandbox/blaxel/sandbox.py b/src/agents/extensions/sandbox/blaxel/sandbox.py index cf34e926bf..f8ff2fe47c 100644 --- a/src/agents/extensions/sandbox/blaxel/sandbox.py +++ b/src/agents/extensions/sandbox/blaxel/sandbox.py @@ -460,7 +460,9 @@ async def _resolve_exposed_port(self, port: int) -> ExposedPortEndpoint: # -- lifecycle ----------------------------------------------------------- + @_data_redacted_async_boundary async def start(self) -> None: + self._validate_manifest_mount_credentials() self._assert_mount_credentials_safe() # When resuming a paused sandbox, _skip_start is set by the client to # avoid reapplying the full manifest over files that may have changed diff --git a/src/agents/extensions/sandbox/daytona/mounts.py b/src/agents/extensions/sandbox/daytona/mounts.py index 9da3eeecbc..7466e61c9a 100644 --- a/src/agents/extensions/sandbox/daytona/mounts.py +++ b/src/agents/extensions/sandbox/daytona/mounts.py @@ -13,6 +13,7 @@ from pathlib import Path from typing import ClassVar, Literal +from ....exceptions import _data_redacted_async_boundary from ....sandbox.entries.mounts.base import InContainerMountStrategy, Mount, MountStrategyBase from ....sandbox.entries.mounts.patterns import RcloneMountPattern from ....sandbox.errors import MountConfigError @@ -193,6 +194,7 @@ def _delegate(self) -> InContainerMountStrategy: def validate_mount(self, mount: Mount) -> None: self._delegate().validate_mount(mount) + @_data_redacted_async_boundary async def activate( self, mount: Mount, @@ -200,6 +202,7 @@ async def activate( dest: Path, base_dir: Path, ) -> list[MaterializedFile]: + self._validate_credential_boundary_for_session(mount, session, dest) _assert_daytona_session(session) if self.pattern.mode == "fuse": await _ensure_fuse_support(session) @@ -225,12 +228,14 @@ async def teardown_for_snapshot( _assert_daytona_session(session) await self._delegate().teardown_for_snapshot(mount, session, path) + @_data_redacted_async_boundary async def restore_after_snapshot( self, mount: Mount, session: BaseSandboxSession, path: Path, ) -> None: + self._validate_credential_boundary_for_session(mount, session, path) _assert_daytona_session(session) if self.pattern.mode == "fuse": await _ensure_fuse_support(session) diff --git a/src/agents/extensions/sandbox/e2b/mounts.py b/src/agents/extensions/sandbox/e2b/mounts.py index f2465043ef..2749ad6ed2 100644 --- a/src/agents/extensions/sandbox/e2b/mounts.py +++ b/src/agents/extensions/sandbox/e2b/mounts.py @@ -5,6 +5,7 @@ from pathlib import Path from typing import ClassVar, Literal +from ....exceptions import _data_redacted_async_boundary from ....sandbox.entries.mounts.base import InContainerMountStrategy, Mount, MountStrategyBase from ....sandbox.entries.mounts.patterns import RcloneMountPattern from ....sandbox.errors import MountConfigError @@ -78,6 +79,7 @@ async def _delegate_for_session(self, session: BaseSandboxSession) -> InContaine def validate_mount(self, mount: Mount) -> None: self._delegate().validate_mount(mount) + @_data_redacted_async_boundary async def activate( self, mount: Mount, @@ -85,6 +87,7 @@ async def activate( dest: Path, base_dir: Path, ) -> list[MaterializedFile]: + self._validate_credential_boundary_for_session(mount, session, dest) _assert_e2b_session(session) if self.pattern.mode == "fuse": await _ensure_fuse_support(session) @@ -111,12 +114,14 @@ async def teardown_for_snapshot( _assert_e2b_session(session) await self._delegate().teardown_for_snapshot(mount, session, path) + @_data_redacted_async_boundary async def restore_after_snapshot( self, mount: Mount, session: BaseSandboxSession, path: Path, ) -> None: + self._validate_credential_boundary_for_session(mount, session, path) _assert_e2b_session(session) if self.pattern.mode == "fuse": await _ensure_fuse_support(session) diff --git a/src/agents/extensions/sandbox/runloop/mounts.py b/src/agents/extensions/sandbox/runloop/mounts.py index 4685685570..bbf1ad3b8b 100644 --- a/src/agents/extensions/sandbox/runloop/mounts.py +++ b/src/agents/extensions/sandbox/runloop/mounts.py @@ -5,6 +5,7 @@ from pathlib import Path from typing import ClassVar, Literal +from ....exceptions import _data_redacted_async_boundary from ....sandbox.entries.mounts.base import InContainerMountStrategy, Mount, MountStrategyBase from ....sandbox.entries.mounts.patterns import RcloneMountPattern from ....sandbox.errors import MountConfigError @@ -124,6 +125,7 @@ async def _delegate_for_session(self, session: BaseSandboxSession) -> InContaine def validate_mount(self, mount: Mount) -> None: self._delegate().validate_mount(mount) + @_data_redacted_async_boundary async def activate( self, mount: Mount, @@ -131,6 +133,7 @@ async def activate( dest: Path, base_dir: Path, ) -> list[MaterializedFile]: + self._validate_credential_boundary_for_session(mount, session, dest) _assert_runloop_session(session) if self.pattern.mode == "fuse": await _ensure_fuse_support(session) @@ -157,12 +160,14 @@ async def teardown_for_snapshot( _assert_runloop_session(session) await self._delegate().teardown_for_snapshot(mount, session, path) + @_data_redacted_async_boundary async def restore_after_snapshot( self, mount: Mount, session: BaseSandboxSession, path: Path, ) -> None: + self._validate_credential_boundary_for_session(mount, session, path) _assert_runloop_session(session) if self.pattern.mode == "fuse": await _ensure_fuse_support(session) diff --git a/src/agents/extensions/sandbox/runloop/sandbox.py b/src/agents/extensions/sandbox/runloop/sandbox.py index a79f3e4ac9..a6c45330ed 100644 --- a/src/agents/extensions/sandbox/runloop/sandbox.py +++ b/src/agents/extensions/sandbox/runloop/sandbox.py @@ -735,6 +735,7 @@ def _coerce_exec_timeout(self, timeout_s: float | None) -> float: return 0.001 return float(timeout_s) + @_data_redacted_async_boundary async def start(self) -> None: """Resume a reconnected Runloop devbox without replaying full setup when possible. @@ -742,6 +743,7 @@ async def start(self) -> None: In that path, Runloop reuses the live machine and only reapplies snapshot or ephemeral manifest state if the cached workspace fingerprint no longer matches. """ + self._validate_manifest_mount_credentials() if self._skip_start: if await self.state.snapshot.restorable(dependencies=self.dependencies): is_running = await self.running() diff --git a/src/agents/extensions/sandbox/vercel/mounts.py b/src/agents/extensions/sandbox/vercel/mounts.py index 6eef300aa9..271d4f17a7 100644 --- a/src/agents/extensions/sandbox/vercel/mounts.py +++ b/src/agents/extensions/sandbox/vercel/mounts.py @@ -7,6 +7,7 @@ from pathlib import Path from typing import ClassVar, Literal, NoReturn +from ....exceptions import _data_redacted_async_boundary from ....sandbox.entries import Mount, S3Mount from ....sandbox.entries.mounts.base import MountStrategyBase from ....sandbox.errors import MountCommandError, MountConfigError @@ -487,6 +488,7 @@ def supports_native_snapshot_detach(self, mount: Mount) -> bool: _ = mount return False + @_data_redacted_async_boundary async def activate( self, mount: Mount, @@ -494,6 +496,7 @@ async def activate( dest: Path, base_dir: Path, ) -> list[MaterializedFile]: + self._validate_credential_boundary_for_session(mount, session, dest) _ = base_dir vercel_session = _require_vercel_session(session) async with vercel_session._s3_mount_operation(force_lock=True): @@ -553,12 +556,14 @@ async def teardown_for_snapshot( raise vercel_session._runtime_record_s3_mount_detached(path) + @_data_redacted_async_boundary async def restore_after_snapshot( self, mount: Mount, session: BaseSandboxSession, path: Path, ) -> None: + self._validate_credential_boundary_for_session(mount, session, path) _validate_s3_mount(mount) vercel_session = _require_vercel_session(session) if not vercel_session._runtime_s3_mount_is_detached(path): diff --git a/src/agents/extensions/sandbox/vercel/sandbox.py b/src/agents/extensions/sandbox/vercel/sandbox.py index 4cfd8a6f95..deaa2b92a4 100644 --- a/src/agents/extensions/sandbox/vercel/sandbox.py +++ b/src/agents/extensions/sandbox/vercel/sandbox.py @@ -1399,6 +1399,10 @@ async def delete(self, session: SandboxSession) -> SandboxSession: async def resume(self, state: SandboxSessionState) -> SandboxSession: if not isinstance(state, VercelSandboxSessionState): raise TypeError("VercelSandboxClient.resume expects a VercelSandboxSessionState") + self._validate_manifest_mount_credentials( + state.manifest, + state._mount_credential_environment_names(), + ) state.assert_trusted_manifest_rebound() if state.s3_mounts_non_resumable or _vercel_s3_mounts(state.manifest): raise MountConfigError( diff --git a/src/agents/sandbox/_mount_security.py b/src/agents/sandbox/_mount_security.py index 23fb9c8df5..91018c18a5 100644 --- a/src/agents/sandbox/_mount_security.py +++ b/src/agents/sandbox/_mount_security.py @@ -395,6 +395,23 @@ def validate_mount_credential_boundaries( has_direct_credentials = any( getattr(mount, field_name, None) is not None for field_name in direct_credential_fields ) + if ( + mount.mount_strategy.credential_boundary == "inside_sandbox" + and pattern_type == "s3files" + ): + raise MountConfigError( + message=( + "the S3 Files mount helper always uses ambient IAM credentials and cannot " + "run inside a model-controlled sandbox; use an external or " + "provider-controlled strategy" + ), + context={ + "mount_path": mount_path.as_posix(), + "mount_type": mount.type, + "mount_strategy": mount.mount_strategy.type, + "mount_pattern": pattern_type, + }, + ) if ( mount.mount_strategy.credential_boundary == "inside_sandbox" and pattern_type in {"fuse", "mountpoint"} diff --git a/src/agents/sandbox/entries/mounts/base.py b/src/agents/sandbox/entries/mounts/base.py index 56d4c795a1..77377e88dd 100644 --- a/src/agents/sandbox/entries/mounts/base.py +++ b/src/agents/sandbox/entries/mounts/base.py @@ -51,6 +51,15 @@ "secret", "token", ) +_OPTION_MAP_CREDENTIAL_SOURCE_NAMES = frozenset( + { + "auth", + "awsprofile", + "iam", + "jwtpath", + "rolearn", + } +) _AUDITED_RCLONE_VERSION = "1.74.4" # This is the audited credential/config-source set from rclone v1.74.4's registered backend # options, plus credential-bearing global server and transport options. Keep the pin assertion in @@ -546,7 +555,17 @@ def _configured_option_map_credential_fields( ) -> tuple[str, ...]: """Return credential-like option names without retaining their values.""" - return (field_prefix,) if options else () + for option_name, option_value in options.items(): + if not isinstance(option_name, str): + return (field_prefix,) + normalized_name = _normalize_driver_option_name(option_name) + if ( + normalized_name in _OPTION_MAP_CREDENTIAL_SOURCE_NAMES + or _is_generic_credential_name(option_name) + or _field_contains_inline_url_credentials(option_name, option_value) + ): + return (field_prefix,) + return () def _rclone_extra_args_use_credential_source(extra_args: list[str]) -> bool: @@ -616,6 +635,7 @@ async def _build_config( ) return config + @_data_redacted_async_boundary async def activate( self, strategy: InContainerMountStrategy, @@ -650,6 +670,7 @@ async def teardown_for_snapshot( config = await self._build_config(strategy, session, include_config_text=False) await strategy.pattern.unapply(session, path, config) + @_data_redacted_async_boundary async def restore_after_snapshot( self, strategy: InContainerMountStrategy, @@ -783,6 +804,22 @@ def _configured_credential_file_paths(self) -> tuple[tuple[str, Path], ...]: configured.append((field_name, Path(value))) return tuple(configured) + def _validate_credential_boundary_for_session( + self, + mount: Mount, + session: BaseSandboxSession, + mount_path: Path, + ) -> None: + from ..._mount_security import validate_mount_credential_boundary_for_session + + effective_mount = mount.model_copy(update={"mount_strategy": self}) + validate_mount_credential_boundary_for_session( + effective_mount, + mount_path, + session.state.manifest, + session.state._mount_credential_environment_names(), + ) + @abc.abstractmethod def validate_mount(self, mount: Mount) -> None: raise NotImplementedError @@ -846,6 +883,7 @@ class InContainerMountStrategy(MountStrategyBase): def validate_mount(self, mount: Mount) -> None: mount.in_container_adapter().validate(self) + @_data_redacted_async_boundary async def activate( self, mount: Mount, @@ -853,6 +891,7 @@ async def activate( dest: Path, base_dir: Path, ) -> list[MaterializedFile]: + self._validate_credential_boundary_for_session(mount, session, dest) return await mount.in_container_adapter().activate(self, session, dest, base_dir) async def deactivate( @@ -872,12 +911,14 @@ async def teardown_for_snapshot( ) -> None: await mount.in_container_adapter().teardown_for_snapshot(self, session, path) + @_data_redacted_async_boundary async def restore_after_snapshot( self, mount: Mount, session: BaseSandboxSession, path: Path, ) -> None: + self._validate_credential_boundary_for_session(mount, session, path) await mount.in_container_adapter().restore_after_snapshot(self, session, path) def build_docker_volume_driver_config( @@ -1380,7 +1421,6 @@ async def unmount( ) -> None: """Deactivate this mount for manifest teardown.""" - self._validate_credential_boundary_for_session(session, dest) await self.mount_strategy.deactivate(self, session, dest, base_dir) def _validate_credential_boundary_for_session( diff --git a/src/agents/sandbox/entries/mounts/patterns.py b/src/agents/sandbox/entries/mounts/patterns.py index 69305874b7..4f088117ef 100644 --- a/src/agents/sandbox/entries/mounts/patterns.py +++ b/src/agents/sandbox/entries/mounts/patterns.py @@ -679,46 +679,15 @@ async def apply( path: Path, config: MountPatternConfig, ) -> None: - s3files_config = _require_mount_config(config, S3FilesMountConfig) - - tool_check = await session.exec("command -v mount.s3files >/dev/null 2>&1") - if not tool_check.ok(): - raise MountToolMissingError( - tool="mount.s3files", - context={"file_system_id": s3files_config.file_system_id}, - ) - - await session.mkdir(path, parents=True) - - device = s3files_config.file_system_id - if s3files_config.subpath: - device = f"{device}:{s3files_config.subpath}" - - options: dict[str, str | None] = dict(s3files_config.extra_options) - if s3files_config.read_only: - options["ro"] = None - if s3files_config.mount_target_ip: - options["mounttargetip"] = s3files_config.mount_target_ip - if s3files_config.access_point: - options["accesspoint"] = s3files_config.access_point - if s3files_config.region: - options["region"] = s3files_config.region - - cmd: list[str] = ["mount", "-t", "s3files"] - if options: - rendered_options = ",".join( - key if value is None else f"{key}={value}" for key, value in options.items() - ) - cmd.extend(["-o", rendered_options]) - cmd.extend([device, sandbox_path_str(path)]) - - result = await session.exec(*cmd, shell=False) - if not result.ok(): - raise MountCommandError( - command=" ".join(shlex.quote(part) for part in cmd), - stderr=result.stderr.decode("utf-8", errors="replace"), - context={"file_system_id": s3files_config.file_system_id}, - ) + _ = (session, path, config) + raise MountConfigError( + message=( + "the S3 Files mount helper always uses ambient IAM credentials and cannot run " + "inside a model-controlled sandbox; use an external or provider-controlled " + "strategy" + ), + context={"pattern": self.type}, + ) async def unapply( self, diff --git a/src/agents/sandbox/sandboxes/docker.py b/src/agents/sandbox/sandboxes/docker.py index d1cff89481..10ec89afc6 100644 --- a/src/agents/sandbox/sandboxes/docker.py +++ b/src/agents/sandbox/sandboxes/docker.py @@ -1498,7 +1498,7 @@ async def create( ) raise SandboxRuntimeError( message="failed to start Docker sandbox container", - error_code=ErrorCode.MOUNT_FAILED, + error_code=ErrorCode.WORKSPACE_START_ERROR, op="start", context={ "backend": "docker", @@ -1655,7 +1655,7 @@ async def _create_container( ) raise SandboxRuntimeError( message="failed to create Docker sandbox container", - error_code=ErrorCode.MOUNT_FAILED, + error_code=ErrorCode.WORKSPACE_START_ERROR, op="start", context={ "backend": "docker", diff --git a/src/agents/sandbox/sandboxes/unix_local.py b/src/agents/sandbox/sandboxes/unix_local.py index 0768e747e5..fa84420d82 100644 --- a/src/agents/sandbox/sandboxes/unix_local.py +++ b/src/agents/sandbox/sandboxes/unix_local.py @@ -189,11 +189,9 @@ async def _apply_manifest( provision_accounts=provision_accounts, ) + @_data_redacted_async_boundary async def apply_manifest(self, *, only_ephemeral: bool = False) -> MaterializationResult: - return await self._apply_manifest( - only_ephemeral=only_ephemeral, - provision_accounts=not only_ephemeral, - ) + return await super().apply_manifest(only_ephemeral=only_ephemeral) async def provision_manifest_accounts(self) -> None: if self.state.manifest.users or self.state.manifest.groups: diff --git a/src/agents/sandbox/session/base_sandbox_session.py b/src/agents/sandbox/session/base_sandbox_session.py index e6c66a8b62..c4aa0ca070 100644 --- a/src/agents/sandbox/session/base_sandbox_session.py +++ b/src/agents/sandbox/session/base_sandbox_session.py @@ -15,6 +15,10 @@ SandboxArchiveLimits, SandboxConcurrencyLimits, ) +from .._mount_security import ( + validate_mount_credential_boundaries, + validate_mount_credential_environment_names, +) from ..apply_patch import PatchFormat, WorkspaceEditor from ..entries import BaseEntry from ..errors import ( @@ -223,6 +227,7 @@ class BaseSandboxSession(abc.ABC): @_data_redacted_async_boundary async def start(self) -> None: + self._validate_manifest_mount_credentials() try: await self._ensure_backend_started() self._start_workspace_root_ready = self.state.workspace_root_ready @@ -1216,8 +1221,16 @@ async def _apply_manifest( async def _validate_manifest_application(self, *, only_ephemeral: bool = False) -> None: _ = only_ephemeral + def _validate_manifest_mount_credentials(self) -> None: + validate_mount_credential_boundaries(self.state.manifest) + validate_mount_credential_environment_names( + self.state.manifest, + self.state._mount_credential_environment_names(), + ) + @_data_redacted_async_boundary async def apply_manifest(self, *, only_ephemeral: bool = False) -> MaterializationResult: + self._validate_manifest_mount_credentials() await self._validate_manifest_application(only_ephemeral=only_ephemeral) return await self._apply_manifest( only_ephemeral=only_ephemeral, diff --git a/src/agents/sandbox/session/manifest_application.py b/src/agents/sandbox/session/manifest_application.py index 67eaecb009..b320b79c2c 100644 --- a/src/agents/sandbox/session/manifest_application.py +++ b/src/agents/sandbox/session/manifest_application.py @@ -3,6 +3,7 @@ from collections.abc import Awaitable, Callable, Sequence from pathlib import Path +from ...exceptions import _data_redacted_async_boundary from ...run_config import DEFAULT_MAX_MANIFEST_ENTRY_CONCURRENCY from .._mount_security import validate_mount_credential_boundaries from ..entries import BaseEntry, Dir, Mount, resolve_workspace_path @@ -28,6 +29,7 @@ def __init__( self._apply_entry = apply_entry self._max_entry_concurrency = max_entry_concurrency + @_data_redacted_async_boundary async def apply_manifest( self, manifest: Manifest, diff --git a/tests/extensions/sandbox/test_blaxel.py b/tests/extensions/sandbox/test_blaxel.py index 753320456a..90cf34f34e 100644 --- a/tests/extensions/sandbox/test_blaxel.py +++ b/tests/extensions/sandbox/test_blaxel.py @@ -19,6 +19,7 @@ from agents.run_config import SandboxRunConfig from agents.sandbox import Manifest, SandboxPathGrant from agents.sandbox.config import DEFAULT_PYTHON_SANDBOX_IMAGE +from agents.sandbox.entries import InContainerMountStrategy, RcloneMountPattern, S3Mount from agents.sandbox.errors import ( ExecTimeoutError, ExecTransportError, @@ -35,6 +36,7 @@ from agents.sandbox.types import ExposedPortEndpoint from agents.sandbox.util.tar_utils import validate_tar_bytes from tests._fake_workspace_paths import resolve_fake_workspace_path +from tests.utils.factories import TestSessionState # --------------------------------------------------------------------------- # Package re-export test @@ -311,6 +313,29 @@ def _make_session( return BlaxelSandboxSession.from_state(state, sandbox=fake, token=token) +@pytest.mark.asyncio +async def test_blaxel_start_rejects_mount_credentials_before_provider_call( + fake_sandbox: _FakeSandboxInstance, +) -> None: + state = _make_state() + state.manifest = Manifest( + entries={ + "remote": S3Mount( + bucket="bucket", + access_key_id="access", + secret_access_key="secret", + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ) + } + ) + session = _make_session(fake_sandbox, state=state) + + with pytest.raises(MountConfigError): + await session.start() + + assert fake_sandbox.process.exec_calls == [] + + # --------------------------------------------------------------------------- # Session tests # --------------------------------------------------------------------------- @@ -2972,7 +2997,11 @@ def __init__(self) -> None: self.exec_calls: list[tuple[tuple[str, ...], dict[str, float]]] = [] self._next_results: list[_FakeExecResultForMount] = [] self._default_result = _FakeExecResultForMount() - self.state = type("State", (), {"session_id": uuid.uuid4()})() + self.state = TestSessionState( + session_id=uuid.uuid4(), + manifest=Manifest(), + snapshot=NoopSnapshot(id="mount-test"), + ) self.writes: dict[Path, bytes] = {} self.persist_skip_paths: list[Path] = [] diff --git a/tests/extensions/sandbox/test_daytona.py b/tests/extensions/sandbox/test_daytona.py index 58bf444c46..b4030af176 100644 --- a/tests/extensions/sandbox/test_daytona.py +++ b/tests/extensions/sandbox/test_daytona.py @@ -30,7 +30,6 @@ Dir, InContainerMountStrategy, Mount, - MountpointMountPattern, RcloneMountPattern, S3Mount, ) @@ -368,7 +367,7 @@ def test_daytona_package_re_exports_backend_symbols(monkeypatch: pytest.MonkeyPa class _RecordingMount(Mount): type: str = "daytona_recording_mount" mount_strategy: InContainerMountStrategy = Field( - default_factory=lambda: InContainerMountStrategy(pattern=MountpointMountPattern()) + default_factory=lambda: InContainerMountStrategy(pattern=RcloneMountPattern()) ) _mounted_paths: list[Path] = PrivateAttr(default_factory=list) _unmounted_paths: list[Path] = PrivateAttr(default_factory=list) @@ -380,8 +379,8 @@ def bind_events(self, events: list[tuple[str, str]]) -> _RecordingMount: def supported_in_container_patterns( self, - ) -> tuple[builtins.type[MountpointMountPattern], ...]: - return (MountpointMountPattern,) + ) -> tuple[builtins.type[RcloneMountPattern], ...]: + return (RcloneMountPattern,) def build_docker_volume_driver_config( self, @@ -1931,8 +1930,8 @@ async def test_ensure_rclone_installs_when_missing() -> None: @pytest.mark.asyncio async def test_activate_calls_preflights_and_delegates() -> None: strategy = DaytonaCloudBucketMountStrategy() - mount = MagicMock() - session = _FakePreflightSession() + mount = S3Mount(bucket="bucket", mount_strategy=strategy) + session = type("DaytonaSandboxSession", (_FakePreflightSession,), {})() dest = Path("/workspace") base_dir = Path("/workspace") @@ -1993,8 +1992,8 @@ async def test_teardown_delegates_without_preflights() -> None: @pytest.mark.asyncio async def test_restore_after_snapshot_reruns_preflights() -> None: strategy = DaytonaCloudBucketMountStrategy() - mount = MagicMock() - session = _FakePreflightSession() + mount = S3Mount(bucket="bucket", mount_strategy=strategy) + session = type("DaytonaSandboxSession", (_FakePreflightSession,), {})() path = Path("/workspace/bucket") with ( diff --git a/tests/extensions/sandbox/test_e2b.py b/tests/extensions/sandbox/test_e2b.py index 075b1e559c..76a0a35d5b 100644 --- a/tests/extensions/sandbox/test_e2b.py +++ b/tests/extensions/sandbox/test_e2b.py @@ -37,7 +37,6 @@ FuseMountPattern, InContainerMountStrategy, Mount, - MountpointMountPattern, RcloneMountPattern, S3Mount, ) @@ -550,7 +549,7 @@ async def restorable(self, *, dependencies: Dependencies | None = None) -> bool: class _RecordingMount(Mount): type: str = "recording_mount" mount_strategy: InContainerMountStrategy = Field( - default_factory=lambda: InContainerMountStrategy(pattern=MountpointMountPattern()) + default_factory=lambda: InContainerMountStrategy(pattern=RcloneMountPattern()) ) _mounted_paths: list[Path] = PrivateAttr(default_factory=list) _unmounted_paths: list[Path] = PrivateAttr(default_factory=list) @@ -563,7 +562,7 @@ def bind_events(self, events: list[tuple[str, str]]) -> _RecordingMount: def supported_in_container_patterns( self, ) -> tuple[builtins.type[MountPatternBase], ...]: - return (MountpointMountPattern,) + return (RcloneMountPattern,) def build_docker_volume_driver_config( self, @@ -1720,7 +1719,7 @@ async def create_snapshot(self) -> object: @pytest.mark.asyncio -async def test_e2b_native_snapshot_falls_back_to_tar_for_blobfuse_cache() -> None: +async def test_e2b_tar_fallback_rejects_ambient_blobfuse_remount() -> None: events: list[tuple[str, str]] = [] mount = _FuseRecordingMount().bind_events(events) sandbox = _FakeE2BSandbox() @@ -1738,17 +1737,16 @@ async def test_e2b_native_snapshot_falls_back_to_tar_for_blobfuse_cache() -> Non ) session = E2BSandboxSession.from_state(state, sandbox=sandbox) - archive = await session.persist_workspace() + with pytest.raises(WorkspaceArchiveReadError) as exc_info: + await session.persist_workspace() - assert archive.read() == b"fake-tar-bytes" + assert isinstance(exc_info.value.cause, MountConfigError) + assert "requires ambient credentials" in str(exc_info.value.cause) assert sandbox.commands.calls command = sandbox.commands.calls[0]["command"] assert isinstance(command, str) assert ".sandbox-blobfuse-cache" in command - assert events == [ - ("unmount", "/workspace/mount"), - ("mount", "/workspace/mount"), - ] + assert events == [("unmount", "/workspace/mount")] @pytest.mark.asyncio diff --git a/tests/extensions/sandbox/test_modal.py b/tests/extensions/sandbox/test_modal.py index 457fcb9b3e..8c2cd6932b 100644 --- a/tests/extensions/sandbox/test_modal.py +++ b/tests/extensions/sandbox/test_modal.py @@ -96,7 +96,7 @@ def test_modal_native_snapshot_reconstructs_mount_credential_skip_paths( class _RecordingMount(Mount): type: str = "modal_recording_mount" mount_strategy: InContainerMountStrategy = Field( - default_factory=lambda: InContainerMountStrategy(pattern=MountpointMountPattern()) + default_factory=lambda: InContainerMountStrategy(pattern=RcloneMountPattern()) ) _events: list[tuple[str, str]] = PrivateAttr(default_factory=list) _teardown_error: str | None = PrivateAttr(default=None) @@ -111,8 +111,8 @@ def bind_teardown_error(self, message: str) -> _RecordingMount: def supported_in_container_patterns( self, - ) -> tuple[builtins.type[MountpointMountPattern], ...]: - return (MountpointMountPattern,) + ) -> tuple[builtins.type[RcloneMountPattern], ...]: + return (RcloneMountPattern,) def build_docker_volume_driver_config( self, @@ -2175,7 +2175,7 @@ async def _fake_exec( "rm", "-rf", "--", - "/workspace/.sandbox-mountpoint-env", + "/workspace/.sandbox-rclone-config", "/workspace/logs/events.jsonl", ] @@ -2267,14 +2267,14 @@ async def _fake_exec( assert "logical" not in commands[0][2] assert "/tmp/openai-agents/session-state/" in commands[0][2] assert "modal-snapshot-directory-ephemeral.tar" in commands[0][2] - assert "for rel in .sandbox-mountpoint-env logs/events.jsonl;" in commands[0][2] + assert "for rel in .sandbox-rclone-config logs/events.jsonl;" in commands[0][2] assert "tar cf" in commands[0][2] assert "-T -" in commands[0][2] assert commands[1] == [ "rm", "-rf", "--", - "/workspace/.sandbox-mountpoint-env", + "/workspace/.sandbox-rclone-config", "/workspace/logs/events.jsonl", ] assert commands[2][0:2] == ["sh", "-lc"] @@ -2417,12 +2417,12 @@ async def _fake_exec( assert str(exc_info.value.cause) == "teardown failed" assert events == [("unmount", "/workspace/actual-1"), ("mount", "/workspace/actual-1")] assert commands[0][0:2] == ["sh", "-lc"] - assert "for rel in .sandbox-mountpoint-env tmp.txt;" in commands[0][2] + assert "for rel in .sandbox-rclone-config tmp.txt;" in commands[0][2] assert commands[1] == [ "rm", "-rf", "--", - "/workspace/.sandbox-mountpoint-env", + "/workspace/.sandbox-rclone-config", "/workspace/tmp.txt", ] assert commands[2][0:2] == ["sh", "-lc"] @@ -2576,14 +2576,14 @@ async def _fake_exec( if rendered == [ "sh", "-lc", - "cd -- /workspace && (tar cf - -- .sandbox-mountpoint-env tmp.txt 2>/dev/null || true)", + "cd -- /workspace && (tar cf - -- .sandbox-rclone-config tmp.txt 2>/dev/null || true)", ]: return ExecResult(stdout=b"ephemeral-backup", stderr=b"", exit_code=0) if rendered == [ "rm", "-rf", "--", - "/workspace/.sandbox-mountpoint-env", + "/workspace/.sandbox-rclone-config", "/workspace/tmp.txt", ]: return ExecResult(stdout=b"", stderr=b"", exit_code=0) @@ -2616,13 +2616,13 @@ async def _fake_call_modal( [ "sh", "-lc", - "cd -- /workspace && (tar cf - -- .sandbox-mountpoint-env tmp.txt 2>/dev/null || true)", + "cd -- /workspace && (tar cf - -- .sandbox-rclone-config tmp.txt 2>/dev/null || true)", ], [ "rm", "-rf", "--", - "/workspace/.sandbox-mountpoint-env", + "/workspace/.sandbox-rclone-config", "/workspace/tmp.txt", ], ] diff --git a/tests/extensions/sandbox/test_runloop.py b/tests/extensions/sandbox/test_runloop.py index 65f7a6857d..9adfe8f6bb 100644 --- a/tests/extensions/sandbox/test_runloop.py +++ b/tests/extensions/sandbox/test_runloop.py @@ -2096,6 +2096,39 @@ async def test_resume_reconnects_suspended_devbox_and_skips_start( assert sdk.devbox.create_calls == [] assert resumed._inner._skip_start is True # noqa: SLF001 + @pytest.mark.asyncio + async def test_skip_start_rejects_mount_credentials_before_provider_call( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + runloop_module = _load_runloop_module(monkeypatch) + + async with runloop_module.RunloopSandboxClient() as client: + session = await client.create( + options=runloop_module.RunloopSandboxClientOptions(pause_on_exit=True), + ) + state = session.state + sdk = _FakeAsyncRunloopSDK.created_instances[-1] + devbox = sdk.devbox.devboxes[state.devbox_id] + devbox.status = "suspended" + resumed = await client.resume(state) + resumed.state.manifest = Manifest( + entries={ + "remote": S3Mount( + bucket="bucket", + access_key_id="access", + secret_access_key="secret", + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ) + } + ) + exec_call_count = len(devbox.exec_calls) + + with pytest.raises(MountConfigError): + await resumed.start() + + assert len(devbox.exec_calls) == exec_call_count + @pytest.mark.asyncio async def test_resume_reconnects_running_devbox_without_pause( self, diff --git a/tests/extensions/sandbox/test_vercel.py b/tests/extensions/sandbox/test_vercel.py index 47a92ecf93..604a081ab3 100644 --- a/tests/extensions/sandbox/test_vercel.py +++ b/tests/extensions/sandbox/test_vercel.py @@ -19,6 +19,7 @@ from agents.sandbox.entries import ( Dir, File, + GCSMount, InContainerMountStrategy, Mount, RcloneMountPattern, @@ -2428,6 +2429,33 @@ async def test_vercel_rejects_sandbox_local_user_arguments( await session.write("notes.txt", io.BytesIO(b"payload"), user="sandbox-user") +@pytest.mark.asyncio +async def test_vercel_resume_rejects_ambient_mount_credentials_before_provider_call( + monkeypatch: pytest.MonkeyPatch, +) -> None: + vercel_module = _load_vercel_module(monkeypatch) + state = vercel_module.VercelSandboxSessionState( + session_id="00000000-0000-0000-0000-000000000020", + manifest=Manifest( + entries={ + "remote": GCSMount( + bucket="test-bucket", + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ) + } + ), + snapshot=NoopSnapshot(id="snapshot"), + sandbox_id="sandbox-with-ambient-credentials", + env={"GOOGLE_APPLICATION_CREDENTIALS": "/tmp/gcp-credentials.json"}, + ) + + with pytest.raises(MountConfigError, match="credential-like environment variables"): + await vercel_module.VercelSandboxClient().resume(state) + + assert _FakeAsyncSandbox.get_calls == [] + assert _FakeAsyncSandbox.create_calls == [] + + @pytest.mark.asyncio async def test_vercel_resume_reconnects_existing_running_sandbox( monkeypatch: pytest.MonkeyPatch, diff --git a/tests/sandbox/test_docker.py b/tests/sandbox/test_docker.py index cd597d72db..0ecf1eb899 100644 --- a/tests/sandbox/test_docker.py +++ b/tests/sandbox/test_docker.py @@ -41,6 +41,7 @@ ) from agents.sandbox.entries.mounts.base import InContainerMountAdapter from agents.sandbox.errors import ( + ErrorCode, ExecTimeoutError, ExecTransportError, InvalidManifestPathError, @@ -1996,6 +1997,7 @@ def create(self, **kwargs: object) -> object: assert access_key not in repr(exc_info.value) assert secret_key not in repr(exc_info.value) + assert exc_info.value.error_code == ErrorCode.WORKSPACE_START_ERROR assert exc_info.value.__cause__ is None assert exc_info.value.__context__ is None assert exc_info.value.context["container_cleanup_confirmed"] is False @@ -2073,6 +2075,7 @@ def remove(self, **kwargs: object) -> None: assert secret_key not in repr(exc_info.value) assert cleanup_secret not in repr(exc_info.value) + assert exc_info.value.error_code == ErrorCode.WORKSPACE_START_ERROR assert exc_info.value.__cause__ is None assert exc_info.value.__context__ is None assert container.remove_calls == [{"force": True}] @@ -2093,6 +2096,32 @@ def remove(self, **kwargs: object) -> None: traceback = traceback.tb_next +@pytest.mark.asyncio +async def test_docker_create_reports_non_mount_start_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class _StartFailureContainer: + id = "created-container" + + def start(self) -> None: + raise RuntimeError("daemon unavailable") + + def remove(self, **kwargs: object) -> None: + _ = kwargs + + docker_client = _FakeCreateDockerClient(_StartFailureContainer()) + client = DockerSandboxClient(docker_client=cast(object, docker_client)) + monkeypatch.setattr(client, "image_exists", lambda _image: True) + + with pytest.raises(SandboxRuntimeError, match="failed to start") as exc_info: + await client.create( + manifest=Manifest(), + options=DockerSandboxClientOptions(image=DEFAULT_PYTHON_SANDBOX_IMAGE), + ) + + assert exc_info.value.error_code == ErrorCode.WORKSPACE_START_ERROR + + @pytest.mark.asyncio async def test_docker_create_container_mounts_s3_with_rclone_driver( monkeypatch: pytest.MonkeyPatch, diff --git a/tests/sandbox/test_mount_security.py b/tests/sandbox/test_mount_security.py index 93c674ae31..c33cf7dffb 100644 --- a/tests/sandbox/test_mount_security.py +++ b/tests/sandbox/test_mount_security.py @@ -1234,10 +1234,12 @@ def test_docker_volume_mount_keeps_credentials_outside_sandbox() -> None: ("mount", "password", "S3_FILES_OPTION_SECRET_SENTINEL"), ("pattern", "token", "S3_FILES_OPTION_SECRET_SENTINEL"), ("mount", "iam", None), - ("pattern", "auth", "S3_FILES_OPTION_SECRET_SENTINEL"), + ("pattern", "awsprofile", "S3_FILES_OPTION_SECRET_SENTINEL"), + ("pattern", "rolearn", "S3_FILES_OPTION_SECRET_SENTINEL"), + ("pattern", "jwtpath", "S3_FILES_OPTION_SECRET_SENTINEL"), ], ) -def test_s3_files_options_reject_arbitrary_values( +def test_s3_files_options_reject_credential_sources( source: str, option_name: str, option_value: str | None, @@ -1268,7 +1270,18 @@ def test_s3_files_options_reject_arbitrary_values( assert sentinel not in repr(raw_error.value) -def test_s3_files_typed_options_remain_supported() -> None: +@pytest.mark.parametrize( + ("mount_options", "pattern_options"), + [ + ({}, {}), + ({"tlsport": "4049"}, {"nfsvers": "4.2"}), + ], + ids=["default", "safe-helper-options"], +) +def test_s3_files_in_container_mount_always_rejects_ambient_iam( + mount_options: dict[str, str | None], + pattern_options: dict[str, str | None], +) -> None: manifest = Manifest( entries={ "remote": S3FilesMount( @@ -1276,12 +1289,22 @@ def test_s3_files_typed_options_remain_supported() -> None: mount_target_ip="10.0.0.10", access_point="ap-123", region="us-east-1", - mount_strategy=InContainerMountStrategy(pattern=S3FilesMountPattern()), + extra_options=mount_options, + mount_strategy=InContainerMountStrategy( + pattern=S3FilesMountPattern( + options=S3FilesMountPattern.S3FilesOptions(extra_options=pattern_options) + ) + ), ) } ) - validate_mount_credential_boundaries(manifest) + with pytest.raises(MountConfigError, match="always uses ambient IAM credentials"): + validate_mount_credential_boundaries(manifest) + + # Safe helper options remain structurally serializable even though the in-container + # execution strategy is unsupported. + sanitize_serialized_mount_credentials({"manifest": manifest.model_dump(mode="json")}) def test_in_container_mount_credentials_cannot_be_acknowledged() -> None: diff --git a/tests/sandbox/test_mounts.py b/tests/sandbox/test_mounts.py index 0bb24ded9d..cf8b78b147 100644 --- a/tests/sandbox/test_mounts.py +++ b/tests/sandbox/test_mounts.py @@ -85,6 +85,29 @@ async def hydrate_workspace(self, data: io.IOBase) -> None: raise AssertionError("hydrate_workspace() should not be called in these tests") +class _MountPreflightSession(_MountConfigSession): + def __init__(self, manifest: Manifest) -> None: + super().__init__() + self.state.manifest = manifest + self.events: list[str] = [] + + async def _ensure_backend_started(self) -> None: + self.events.append("backend") + + async def _prepare_backend_workspace(self) -> None: + self.events.append("prepare") + + async def _ensure_runtime_helpers(self) -> None: + self.events.append("helpers") + + async def _validate_manifest_application(self, *, only_ephemeral: bool = False) -> None: + _ = only_ephemeral + self.events.append("manifest") + + async def _after_start_failed(self) -> None: + self.events.append("cleanup") + + class _MountpointApplySession(BaseSandboxSession): def __init__(self, *, mount_exit_code: int = 0, mount_stderr: bytes = b"") -> None: self.state = TestSessionState( @@ -690,38 +713,51 @@ async def test_s3_files_mount_builds_runtime_config_with_pattern_defaults() -> N @pytest.mark.asyncio -async def test_s3_files_pattern_mounts_with_helper_options() -> None: +async def test_s3_files_pattern_rejects_before_helper_side_effects() -> None: session = _MountpointApplySession() pattern = S3FilesMountPattern() - await pattern.apply( - session, - Path("/workspace/remote"), - S3FilesMountConfig( - file_system_id="fs-1234567890abcdef0", - subpath="/datasets", - mount_target_ip="10.99.1.209", - access_point="fsap-123", - region="us-east-1", - extra_options={"tlsport": "4049"}, - mount_type="s3_files_mount", - read_only=True, - ), + with pytest.raises(MountConfigError, match="always uses ambient IAM credentials"): + await pattern.apply( + session, + Path("/workspace/remote"), + S3FilesMountConfig( + file_system_id="fs-1234567890abcdef0", + subpath="/datasets", + mount_target_ip="10.99.1.209", + access_point="fsap-123", + region="us-east-1", + extra_options={"tlsport": "4049"}, + mount_type="s3_files_mount", + read_only=True, + ), + ) + + assert session.exec_calls == [] + assert session.write_calls == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("operation", ["start", "apply_manifest"]) +async def test_session_mount_preflight_runs_before_lifecycle_side_effects(operation: str) -> None: + session = _MountPreflightSession( + Manifest( + entries={ + "remote": S3FilesMount( + file_system_id="fs-1234567890abcdef0", + mount_strategy=InContainerMountStrategy(pattern=S3FilesMountPattern()), + ) + } + ) ) - assert session.exec_calls[:2] == [ - ["sh", "-lc", "command -v mount.s3files >/dev/null 2>&1"], - ["mkdir", "-p", "/workspace/remote"], - ] - assert session.exec_calls[2] == [ - "mount", - "-t", - "s3files", - "-o", - ("tlsport=4049,ro,mounttargetip=10.99.1.209,accesspoint=fsap-123,region=us-east-1"), - "fs-1234567890abcdef0:/datasets", - "/workspace/remote", - ] + with pytest.raises(MountConfigError, match="always uses ambient IAM credentials"): + if operation == "start": + await session.start() + else: + await session.apply_manifest() + + assert session.events == [] @pytest.mark.asyncio @@ -1358,22 +1394,45 @@ async def test_rclone_mount_rejects_credentials_before_public_apply_side_effects traceback = traceback.tb_next +@pytest.mark.parametrize("operation", ["activate", "restore_after_snapshot"]) @pytest.mark.asyncio -async def test_rclone_mount_rejects_credentials_before_public_unmount_side_effects() -> None: +async def test_in_container_strategy_rejects_credentials_before_direct_side_effects( + operation: str, +) -> None: + sentinel = "RCLONE_DIRECT_STRATEGY_SECRET_SENTINEL" + strategy = InContainerMountStrategy(pattern=RcloneMountPattern()) mount = S3Mount( bucket="bucket", access_key_id="access", - secret_access_key="secret", - mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + secret_access_key=sentinel, + mount_strategy=strategy, ) session = _GeneratedConfigApplySession( session_id=uuid.UUID("12345678-1234-5678-1234-567812345678"), ) - with pytest.raises(MountConfigError, match="cannot be passed to a helper inside"): - await mount.unmount(session, Path("/workspace/remote"), Path.cwd()) + with pytest.raises(MountConfigError) as exc_info: + if operation == "activate": + await strategy.activate(mount, session, Path("/workspace/remote"), Path.cwd()) + else: + await strategy.restore_after_snapshot(mount, session, Path("/workspace/remote")) + assert sentinel not in repr(exc_info.value) assert session.exec_calls == [] + assert session.write_calls == [] + + +@pytest.mark.asyncio +async def test_s3_files_public_unmount_remains_available_for_legacy_cleanup() -> None: + mount = S3FilesMount( + file_system_id="fs-1234567890abcdef0", + mount_strategy=InContainerMountStrategy(pattern=S3FilesMountPattern()), + ) + session = _MountpointApplySession() + + await mount.unmount(session, Path("/workspace/remote"), Path.cwd()) + + assert session.exec_calls == [["sh", "-lc", "umount /workspace/remote || true"]] @pytest.mark.asyncio @@ -1395,11 +1454,8 @@ async def test_public_mount_rejects_session_manifest_credential_file_before_side assert session.write_calls == [] -@pytest.mark.parametrize("operation", ["apply", "unmount"]) @pytest.mark.asyncio -async def test_public_mount_rejects_opaque_credential_authority_with_manifest_file( - operation: str, -) -> None: +async def test_public_mount_rejects_opaque_credential_authority_with_manifest_file() -> None: mount = S3Mount( bucket="bucket", mount_strategy=DockerVolumeMountStrategy( @@ -1413,10 +1469,7 @@ async def test_public_mount_rejects_opaque_credential_authority_with_manifest_fi session.state.manifest.entries["credentials.json"] = File(content=b"credential") with pytest.raises(MountConfigError, match="opaque mount credential commands"): - if operation == "apply": - await mount.apply(session, Path("/workspace/remote"), Path.cwd()) - else: - await mount.unmount(session, Path("/workspace/remote"), Path.cwd()) + await mount.apply(session, Path("/workspace/remote"), Path.cwd()) assert session.exec_calls == [] assert session.write_calls == [] @@ -1456,12 +1509,12 @@ async def test_public_mount_rejects_opaque_credential_authority_with_manifest_fi mount_strategy=InContainerMountStrategy( pattern=S3FilesMountPattern( options=S3FilesMountPattern.S3FilesOptions( - extra_options={"auth": "enabled"}, + extra_options={"tlsport": "4049"}, ) ), ), ), - id="s3files-pattern-options", + id="s3files-inherent-iam", ), ], ) diff --git a/tests/sandbox/test_unix_local.py b/tests/sandbox/test_unix_local.py index c2fdf65e32..0186e601f6 100644 --- a/tests/sandbox/test_unix_local.py +++ b/tests/sandbox/test_unix_local.py @@ -9,6 +9,7 @@ import pytest from agents.sandbox import SandboxPathGrant +from agents.sandbox.entries import InContainerMountStrategy, S3FilesMount, S3FilesMountPattern from agents.sandbox.errors import PtySessionNotFoundError from agents.sandbox.manifest import Manifest from agents.sandbox.sandboxes.unix_local import ( @@ -73,6 +74,21 @@ def _unexpected_mkdtemp(*args: object, **kwargs: object) -> str: ) +@pytest.mark.asyncio +async def test_unix_local_delete_cleans_up_legacy_s3_files_mount() -> None: + client = UnixLocalSandboxClient() + session = await client.create(manifest=Manifest(), snapshot=None, options=None) + workspace_root = Path(session.state.manifest.root) + session.state.manifest.entries["remote"] = S3FilesMount( + file_system_id="fs-1234567890abcdef0", + mount_strategy=InContainerMountStrategy(pattern=S3FilesMountPattern()), + ) + + await client.delete(session) + + assert not workspace_root.exists() + + class TestUnixLocalPty: @pytest.mark.asyncio async def test_tty_fd_close_is_owned_without_blocking_termination( diff --git a/tests/test_run_examples_script.py b/tests/test_run_examples_script.py index 09794c4569..5b568c8108 100644 --- a/tests/test_run_examples_script.py +++ b/tests/test_run_examples_script.py @@ -9,7 +9,6 @@ def test_default_auto_skip_excludes_prerequisite_bound_examples() -> None: expected = { "examples/sandbox/docker/mounts/azure_mount_read_write.py", "examples/sandbox/docker/mounts/gcs_mount_read_write.py", - "examples/sandbox/docker/mounts/s3_files_mount_read_write.py", "examples/sandbox/docker/mounts/s3_mount_read_write.py", "examples/sandbox/extensions/daytona/usaspending_text2sql/setup_db.py", "examples/sandbox/extensions/temporal/temporal_sandbox_agent.py",