diff --git a/.agents/references/sandbox-runtime-boundary.md b/.agents/references/sandbox-runtime-boundary.md index 221c7e16ec..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. +- 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/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..5fd3a3df82 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. 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. ## 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/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 dba5ecbe40..fcf0798a01 100644 --- a/src/agents/extensions/sandbox/blaxel/mounts.py +++ b/src/agents/extensions/sandbox/blaxel/mounts.py @@ -17,19 +17,26 @@ from __future__ import annotations +import asyncio +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, 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 @@ -69,11 +76,13 @@ 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: _build_mount_config(mount, mount_path="/validate") + @_data_redacted_async_boundary async def activate( self, mount: Mount, @@ -133,6 +142,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 +291,191 @@ 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 _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 + + +async def _remove_mount_credential_file( + session: BaseSandboxSession, + credential_path: str, +) -> None: + 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: + 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") # 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: @@ -326,21 +503,40 @@ 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 "" + 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}, ) + operation_succeeded = True + except asyncio.CancelledError: + operation_cancelled = True + raise finally: - # Clean up credentials file. if cred_path: - await _exec(session, f"rm -f {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") @@ -351,12 +547,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] = [] @@ -374,18 +568,34 @@ 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 "" + 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}, ) + operation_succeeded = True + except asyncio.CancelledError: + operation_cancelled = True + raise finally: if key_path: - await _exec(session, f"rm -f {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: @@ -401,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: @@ -419,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: @@ -439,6 +649,8 @@ async def _unmount_bucket(session: BaseSandboxSession, mount_path: str) -> None: mount_path, result.exit_code, ) + return False + return True # --------------------------------------------------------------------------- @@ -541,6 +753,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..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,6 +1107,7 @@ async def create( ) -> SandboxSession: if manifest is None: manifest = Manifest(root=DEFAULT_BLAXEL_WORKSPACE_ROOT) + self._validate_manifest_mount_credentials(manifest, options.env_vars) timeouts_in = options.timeouts if isinstance(timeouts_in, BlaxelTimeouts): @@ -1132,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, @@ -1145,7 +1189,9 @@ async def resume( """ if not isinstance(state, BlaxelSandboxSessionState): raise TypeError("BlaxelSandboxClient.resume expects a BlaxelSandboxSessionState") - state.assert_path_grants_rebound() + 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() blaxel_sandbox = None @@ -1179,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/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..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, *, @@ -1457,6 +1462,7 @@ async def create( if manifest is None: manifest = Manifest() + self._validate_manifest_mount_credentials(manifest) if manifest.root != "/workspace": raise ConfigurationError( message=( @@ -1501,12 +1507,13 @@ 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( "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, @@ -1518,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 038473e70e..9da3eeecbc 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 @@ -175,13 +175,15 @@ 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" 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..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,6 +1257,7 @@ async def create( ) -> SandboxSession: if manifest is None: manifest = Manifest(root=DEFAULT_DAYTONA_WORKSPACE_ROOT) + self._validate_manifest_mount_credentials(manifest, options.env_vars) timeouts_in = options.timeouts if isinstance(timeouts_in, DaytonaSandboxTimeouts): @@ -1320,13 +1323,15 @@ 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") - state.assert_path_grants_rebound() + self._validate_manifest_mount_credentials(state.manifest, state.base_env_vars) + state.assert_trusted_manifest_rebound() daytona_sandbox = None reconnected = False @@ -1357,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/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..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,6 +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, options.envs) sandbox_type = _coerce_sandbox_type(options.sandbox_type) @@ -1764,13 +1769,15 @@ 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") - state.assert_path_grants_rebound() + self._validate_manifest_mount_credentials(state.manifest, state.base_envs) + state.assert_trusted_manifest_rebound() sandbox_type = _coerce_sandbox_type(state.sandbox_type) SandboxClass = _import_sandbox_class(sandbox_type) @@ -1817,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 a7dcb74a99..70f930c717 100644 --- a/src/agents/extensions/sandbox/modal/mounts.py +++ b/src/agents/extensions/sandbox/modal/mounts.py @@ -2,10 +2,13 @@ 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 +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 @@ -25,6 +28,10 @@ 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 d3a3665885..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, *, @@ -2003,6 +2019,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") @@ -2171,18 +2188,20 @@ async def delete(self, session: SandboxSession) -> SandboxSession: return session + @_data_redacted_async_boundary async def resume( self, state: SandboxSessionState, ) -> 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: 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/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..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,6 +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)) + 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 @@ -1660,6 +1667,7 @@ async def delete(self, session: SandboxSession) -> SandboxSession: pass return session + @_data_redacted_async_boundary async def resume( self, state: SandboxSessionState, @@ -1673,7 +1681,11 @@ async def resume( """ if not isinstance(state, RunloopSandboxSessionState): raise TypeError("RunloopSandboxClient.resume expects a RunloopSandboxSessionState") - state.assert_path_grants_rebound() + self._validate_manifest_mount_credentials( + state.manifest, + {**state.base_env_vars, **state.secret_refs}, + ) + state.assert_trusted_manifest_rebound() devbox = None reconnected = False @@ -1717,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 b11954f112..6eef300aa9 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["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 4da5fb4164..4cfd8a6f95 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__( @@ -475,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, @@ -497,12 +491,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 +1335,7 @@ def _wrap_session( dependencies=self._resolve_dependencies(), ) + @_data_redacted_async_boundary async def create( self, *, @@ -1349,18 +1344,7 @@ async def create( options: VercelSandboxClientOptions, ) -> SandboxSession: resolved_manifest = _resolve_manifest_root(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"}, - ) + 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) @@ -1411,10 +1395,11 @@ 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") - 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=( @@ -1482,6 +1467,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 243a6d2c9e..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, @@ -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) @@ -773,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, *, @@ -900,7 +903,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 @@ -1095,6 +1098,7 @@ def _extract_name(raw: Any) -> str | None: return "" + @_data_redacted_boundary def to_string( self, *, @@ -1153,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, @@ -1194,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], @@ -3081,7 +3087,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..23fb9c8df5 --- /dev/null +++ b/src/agents/sandbox/_mount_security.py @@ -0,0 +1,1671 @@ +from __future__ import annotations + +import copy +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", + "current_agent_id", + "current_agent_key", + "current_agent_name", + "session_state", + "sessions_by_agent", + } +) +_RELEASED_IN_CONTAINER_MOUNT_STRATEGY_TYPES = frozenset( + { + "blaxel_cloud_bucket", + "daytona_cloud_bucket", + "e2b_cloud_bucket", + "in_container", + "runloop_cloud_bucket", + "vercel_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, ...]: + candidates: Iterable[object] + if isinstance(names, Mapping): + candidates = names.keys() + 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, + *, + 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(), *additional_mounts) + 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, + mount_path: Path, + manifest: Manifest, + environment_names: object, +) -> None: + """Apply the shared mount boundary to a direct mount lifecycle call.""" + + validate_mount_credential_boundaries( + manifest, + additional_mounts=((mount, mount_path),), + ) + validate_mount_credential_environment_names( + manifest, + environment_names, + additional_mounts=((mount, mount_path),), + ) + + +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, + *, + additional_mounts: Iterable[tuple[Mount, Path]] = (), +) -> None: + """Reject credential-bearing mounts that can expose credentials inside the sandbox.""" + + 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( + ( + *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_credential_file_sources(manifest, mount, mount_path) + + boundary = mount.mount_strategy.credential_boundary + if boundary == "outside_sandbox": + 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; use an anonymous endpoint or an external or provider-native mount " + "strategy" + ), + context=context, + ) + raise MountConfigError( + message=( + "mount strategy must declare its credential boundary before it can be used " + "with explicit credentials" + ), + context=context, + ) + + +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.""" + + 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 credentials_by_path: + return manifest, {} + + redacted = manifest.model_copy(deep=True) + redacted_mounts = _mounts_by_entry_path(redacted) + 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() + return redacted, credentials_by_path + + +def sanitize_serialized_mount_credentials( + payload: dict[str, object], +) -> tuple[dict[str, object], dict[str, 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" + ) + _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, {} + 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", "env", "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 + 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( + entries, + prefix="", + credentials_by_path=credentials_by_path, + entry_paths=entry_paths, + has_serialized_files=has_serialized_files, + root=root, + ) + 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( + payload: dict[str, object], +) -> dict[str, object]: + """Strip mount credentials and preserve the trusted-rebind marker.""" + + _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 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, 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 credentials_by_path): + raise ValueError("Persisted sandbox mount credential marker contains an invalid path") + 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 + + +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]: + state_type = payload.get("type") + if not isinstance(state_type, str): + raise ValueError("RunState sandbox session_state must include a string type") + + 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 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 {} + 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)] + 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) + 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 = ( + 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 slots_by_path + + +def rebind_manifest_mount_credentials( + manifest: Manifest, + trusted_manifest: Manifest | None, + 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 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) + 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, field_names in credentials_by_path.items(): + rebound_mounts[path]._restore_configured_credentials(trusted_mounts[path], field_names) + 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], + *, + prefix: str, + credentials_by_path: dict[str, list[str]], + entry_paths: frozenset[str], + has_serialized_files: bool, + 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 + } + 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): + raise ValueError("Persisted sandbox manifest entries must contain objects") + name = str(raw_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 = ( + 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_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 + ) + 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_normalized_key(nested_pattern, "configfilepath"): + 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 mount credential source must not be a manifest entry" + ) + if "config_file_path" in pattern: + 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: + credentials_by_path.setdefault(entry_path, []).extend(removed_fields) + + 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, + 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], + *, + root: Path, + 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) + 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): + 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, + *, + 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 + 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 _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): + 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 + + +def _without_credentials(mount: Mount) -> Mount: + redacted = mount.model_copy(deep=True) + redacted._clear_configured_credentials() + return redacted + + +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: + root = posix_path_as_path(coerce_posix_path(manifest.root)) + 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=( + "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(), + }, + ) + + +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/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 9c8bcf1705..56d4c795a1 100644 --- a/src/agents/sandbox/entries/mounts/base.py +++ b/src/agents/sandbox/entries/mounts/base.py @@ -3,24 +3,583 @@ import abc import builtins import inspect +import re import warnings from collections.abc import Mapping from pathlib import Path -from typing import TYPE_CHECKING, ClassVar, Literal - -from pydantic import BaseModel, Field, SerializeAsAny, field_validator - +from typing import TYPE_CHECKING, Any, ClassVar, Literal, cast +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 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", + "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[Any, Any], + *, + 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. @@ -124,6 +683,11 @@ 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" + _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: @@ -165,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 @@ -223,6 +841,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 +892,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 +971,31 @@ class Mount(BaseEntry): ephemeral: bool = True 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 @@ -409,6 +1054,308 @@ 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 + ] + 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.""" + + for field_name in self._credential_field_names: + 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: + 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): + 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") + + 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 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 ( + "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, @@ -421,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, dest) return await self.mount_strategy.activate(self, session, dest, base_dir) + @_data_redacted_async_boundary async def unmount( self, session: BaseSandboxSession, @@ -431,8 +1380,23 @@ 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( + 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(), + ) + 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 6aeeea974b..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, *, @@ -701,6 +839,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: @@ -777,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}, ) @@ -806,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 @@ -874,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, @@ -903,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, @@ -959,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 7623c39958..a67b137190 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,24 @@ 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 @@ -97,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 444129159e..6e552b7fb4 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,16 @@ 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", + } + ) + _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 8e3838b3bc..47756426d2 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,17 @@ 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", + } + ) + _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 @@ -171,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 33490eaf29..7f936bf94e 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,10 @@ class R2Mount(_ConfiguredMount): type: Literal["r2_mount"] = "r2_mount" + _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 @@ -96,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 e44d95ba2b..18d7ad9763 100644 --- a/src/agents/sandbox/entries/mounts/providers/s3.py +++ b/src/agents/sandbox/entries/mounts/providers/s3.py @@ -1,10 +1,10 @@ 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 +from ..base import _RCLONE_S3_CREDENTIAL_DRIVER_OPTION_NAMES, DockerVolumeMountStrategy from ..patterns import ( MountPattern, MountPatternConfig, @@ -20,6 +20,13 @@ class S3Mount(_ConfiguredMount): type: Literal["s3_mount"] = "s3_mount" + _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 @@ -129,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 62d88e202f..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, ) @@ -15,9 +16,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 ( @@ -271,8 +273,32 @@ 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, + *, + 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(), *additional_mounts): + 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(): @@ -282,6 +308,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 @@ -388,8 +429,17 @@ 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( + 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 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: @@ -397,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 c3a4fda9ad..2c28380d1a 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,11 @@ _build_agent_identity_keys_by_id, ) 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, +) from .capabilities import Capability from .entries import BaseEntry, Dir, Mount, resolve_workspace_path from .manifest import Manifest @@ -194,6 +200,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 +295,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 +323,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 +580,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( @@ -772,8 +792,41 @@ 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: + 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, + resume_manifest, + run_as_user=cls._agent_run_as_user(agent), + ) + 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 + ), + ) + processed_state = session_state.model_copy( + update={"manifest": processed_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 + if rebound_state.path_grants_require_rebind and trusted_manifest is not None: resume_manifest = resume_manifest.model_copy( update={ "extra_path_grants": tuple( @@ -781,15 +834,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: - return session_state - processed_state = session_state.model_copy(update={"manifest": processed_manifest}) - return processed_state.rebind_persisted_path_grants(processed_manifest) + if processed_resume_manifest is None: + return rebound_state + 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 ac7100399b..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, *, @@ -1470,6 +1474,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( @@ -1478,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 @@ -1531,13 +1558,14 @@ async def delete(self, session: SandboxSession) -> SandboxSession: volume.remove() return session + @_data_redacted_async_boundary async def resume( self, state: SandboxSessionState, ) -> 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 @@ -1563,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) @@ -1611,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 615987da5a..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, *, @@ -1102,6 +1104,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. @@ -1156,17 +1159,19 @@ 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, 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) + @_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/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..ad10976a3a 100644 --- a/src/agents/sandbox/session/sandbox_client.py +++ b/src/agents/sandbox/session/sandbox_client.py @@ -5,16 +5,19 @@ from pydantic import BaseModel, ConfigDict, model_serializer +from ...exceptions import _data_redacted_boundary +from .._mount_security import ( + REDACTED_HOST_PATH_GRANT_PATHS_KEY, + validate_mount_credential_boundaries, + validate_mount_credential_environment_names, +) from ..manifest import Manifest from ..snapshot import SnapshotBase, SnapshotSpec from .base_sandbox_session import BaseSandboxSession 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") @@ -105,6 +108,15 @@ class BaseSandboxClient(abc.ABC, Generic[ClientOptionsT]): supports_default_options: bool = False _dependencies: Dependencies | None = None + @staticmethod + 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: return None @@ -173,8 +185,13 @@ 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.""" + self._validate_manifest_mount_credentials( + state.manifest, + state._mount_credential_environment_names(), + ) redacted_paths = set(state.path_grants_require_rebind) persistent_grants = [] for grant in state.manifest.extra_path_grants: @@ -193,12 +210,19 @@ def serialize_session_state(self, state: SandboxSessionState) -> dict[str, objec return payload @staticmethod + @_data_redacted_boundary 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.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 f5f38583e1..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, @@ -14,11 +15,24 @@ 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, + sanitize_serialized_session_state_mount_credentials, + validate_mount_credential_boundaries, +) 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"] -REDACTED_HOST_PATH_GRANT_PATHS_KEY = "__openai_agents_redacted_host_path_grant_paths" class SandboxSessionState(BaseModel): @@ -34,11 +48,26 @@ class SandboxSessionState(BaseModel): _subclass_registry: ClassVar[dict[str, SessionStateClass]] = {} _path_grants_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, ...]: return self._path_grants_require_rebind + @property + def mount_credentials_require_rebind(self) -> tuple[str, ...]: + 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", "env", "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: """Auto-register every subclass by its ``type`` field default.""" @@ -63,6 +92,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. @@ -76,7 +106,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 +116,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 +151,21 @@ def _mark_persisted_path_grants( sanitized_manifest = state.manifest.model_copy( update={"extra_path_grants": persistent_grants}, ) + sanitized_manifest, serialized_mount_credentials = redact_manifest_mount_credentials( + sanitized_manifest + ) + redacted_mount_value = payload.get(REDACTED_MOUNT_CREDENTIAL_PATHS_KEY) + 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( dict.fromkeys( @@ -123,6 +176,19 @@ def _mark_persisted_path_grants( ) ) ) + 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( @@ -174,14 +240,67 @@ 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, + 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 = {} + 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) - if self.type: - data["type"] = self.type - if self.session_id: - data["session_id"] = self.session_id - return 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.py b/tests/extensions/sandbox/test_blaxel.py index 99efdeb779..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 @@ -2832,6 +2972,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 +2982,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..1f2d4d10da 100644 --- a/tests/extensions/sandbox/test_blaxel_mounts.py +++ b/tests/extensions/sandbox/test_blaxel_mounts.py @@ -1,29 +1,112 @@ from __future__ import annotations +import asyncio +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.entries import S3Mount +from agents.sandbox.errors import MountCommandError, MountConfigError +from agents.sandbox.manifest import Manifest _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" + + +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(), + manifest=Manifest(), + _mount_credential_environment_names=lambda: (), + ) + 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: + 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: + 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() @@ -54,3 +137,200 @@ 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")] + 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: + from agents.extensions.sandbox.blaxel.mounts import _write_mount_credential_file + + session = _RecordingSession() + session.checked_failure = "chmod" + secret = "credential-sentinel" + + with pytest.raises(MountCommandError): + 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) + + +@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(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( + provider="s3", + bucket="bucket", + mount_path="/mnt/data", + access_key_id="access", + 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 3f707ba3c9..e399980b28 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: @@ -870,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 02dcb3254d..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, @@ -1622,6 +1650,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..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, @@ -85,6 +87,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" @@ -559,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( @@ -627,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" @@ -847,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) @@ -1281,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", @@ -1649,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 a63582319c..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( @@ -384,6 +415,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 @@ -637,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, @@ -2086,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 @@ -2176,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] @@ -2320,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] @@ -2473,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}") @@ -2504,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", "")] @@ -2635,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_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..47a92ecf93 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,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 == "inside_sandbox" assert package_module.VercelSandboxClient is vercel_module.VercelSandboxClient assert package_module.VercelSandboxSessionState is vercel_module.VercelSandboxSessionState @@ -556,22 +557,92 @@ 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 == [] +@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: + 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"], + ) + }, + ) + + +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, @@ -667,15 +738,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 = { @@ -693,9 +764,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, @@ -707,9 +775,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") @@ -1034,12 +1099,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"), @@ -1053,9 +1118,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 @@ -1080,11 +1143,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() @@ -1856,111 +1914,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, @@ -2986,9 +2939,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={ @@ -3025,6 +2976,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]", ".", ], @@ -3041,9 +2993,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 5eae1c43b2..65569aaa2b 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={ @@ -200,31 +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 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 new file mode 100644 index 0000000000..93c674ae31 --- /dev/null +++ b/tests/sandbox/test_mount_security.py @@ -0,0 +1,1438 @@ +from __future__ import annotations + +from pathlib import Path +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 ( + 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 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={ + "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_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())) + + 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 + + +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"), + [ + ( + 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",), + ), + ( + 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( + mount: Any, + credential_fields: tuple[str, ...], +) -> None: + 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", + [ + 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) + + 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, manifest, 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 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) + + +@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()), + credentials=False, + ) + + 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) + + +@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())) + + with pytest.raises(MountConfigError, match="cannot be passed to a helper"): + validate_mount_credential_boundaries(manifest) + + +@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 "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="not supported"): + _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 = _MountSecuritySessionState( + 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(remote_name=":s3")), + credentials=False, + ) + + 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..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, @@ -28,8 +29,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 @@ -53,6 +56,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 @@ -136,7 +142,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"), @@ -144,6 +156,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) @@ -154,7 +168,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 @@ -165,7 +180,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: @@ -300,15 +320,88 @@ 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 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() @@ -658,6 +751,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" @@ -668,6 +764,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() @@ -794,6 +916,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 @@ -946,7 +1071,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( @@ -976,7 +1101,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" ) @@ -1167,6 +1292,291 @@ 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.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", + [ + 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") @@ -1249,6 +1659,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 59a64ff643..134077bf08 100644 --- a/tests/sandbox/test_runtime.py +++ b/tests/sandbox/test_runtime.py @@ -53,15 +53,18 @@ ) from agents.sandbox.entries import ( BaseEntry, + DockerVolumeMountStrategy, File, InContainerMountStrategy, MountpointMountPattern, + RcloneMountPattern, S3Mount, ) from agents.sandbox.errors import ( ExecNonZeroError, ExecTransportError, InvalidManifestPathError, + MountConfigError, WorkspaceArchiveWriteError, ) from agents.sandbox.files import EntryKind, FileEntry @@ -101,6 +104,12 @@ from tests.utils.simple_session import SimpleListSession +class _CredentialEnvironmentTestState(SandboxSessionState): + __test__ = False + type: Literal["credential-environment-test"] = "credential-environment-test" + base_envs: dict[str, str] + + class _FakeSession(BaseSandboxSession): def __init__( self, @@ -114,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 @@ -143,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: @@ -753,6 +764,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" @@ -1238,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()), ) } ) @@ -1571,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()) @@ -2068,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()), ), } ) @@ -2109,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()), ), } ) @@ -2149,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()), ), } ) @@ -3397,6 +3597,277 @@ 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=DockerVolumeMountStrategy(driver="rclone"), + ) + } + ) + 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_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={ + "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_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", + 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( + 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..877ee0ea11 100644 --- a/tests/sandbox/test_session_state_roundtrip.py +++ b/tests/sandbox/test_session_state_roundtrip.py @@ -17,6 +17,18 @@ from pydantic import ConfigDict, ValidationError, field_serializer, field_validator from agents.sandbox import Manifest, SandboxPathGrant +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 ( BaseSandboxClient, @@ -52,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" @@ -82,7 +101,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()) @@ -130,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 # --------------------------------------------------------------------------- @@ -353,7 +385,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 +428,1048 @@ 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=DockerVolumeMountStrategy(driver="rclone"), + ) + } + ) + 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": ["access_key_id", "secret_access_key", "session_token"] + } + + 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() + + 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_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: + 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="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() + 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() + trusted_manifest = Manifest( + entries={ + "remote": S3Mount( + bucket="example", + access_key_id="access-key", + secret_access_key="secret-key", + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ) + } + ) + 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", exclude={"manifest"})) + payload["manifest"] = state.manifest.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", 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"]) + 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", "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] = {"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] = { + "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 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() + 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=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"]) + 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() + + 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() + trusted_manifest = Manifest( + entries={ + "remote": S3Mount( + bucket="trusted-bucket", + access_key_id="trusted-access", + secret_access_key="trusted-secret", + mount_strategy=DockerVolumeMountStrategy(driver="rclone"), + ), + "public": S3Mount( + bucket="public-bucket", + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ), + } + ) + 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")) + ), + ), + } + ) + 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_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" + 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", 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) + 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( + ("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", + [ + "MARKER_SECRET_SENTINEL", + ["MARKER_SECRET_SENTINEL"], + {"remote": ["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) + + @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 83bbdb7c1f..fc8aad9f50 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,6 +108,15 @@ ) from agents.sandbox import Manifest from agents.sandbox.capabilities.capability import Capability +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 @@ -154,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}" @@ -5587,6 +5605,7 @@ def test_supported_schema_versions_match_released_boundary(self): "1.11", "1.12", "1.13", + "1.14", CURRENT_SCHEMA_VERSION, } ) @@ -6079,6 +6098,1679 @@ 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"}, + }, + }, + "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"}, + } + 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 + 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": ["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 + async def test_run_state_rejects_rclone_driver_exposure_options( + self, + location: str, + driver_options: dict[str, 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": "docker_volume", + "driver": "rclone", + "driver_options": driver_options, + }, + } + } + }, + "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="cannot expose credentials"): + await RunState.from_json(agent, payload) + + @pytest.mark.asyncio + @pytest.mark.parametrize("location", ["active", "inactive"]) + @pytest.mark.parametrize( + "option_name", + [ + "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 + ) + ) + ) + }, + }, + }, + } + }, + "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": {}, + }, + "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}, + }, + } + } + }, + "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="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}, + }, + } + } + }, + "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_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, + 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", + "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.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( + 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" + 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) + + 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.""" 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."}, + }