Skip to content

Commit 4970fd6

Browse files
authored
fix: keep mountpoint credentials out of sandbox commands (#3429)
1 parent 4bd459e commit 4970fd6

4 files changed

Lines changed: 191 additions & 29 deletions

File tree

src/agents/sandbox/entries/mounts/patterns.py

Lines changed: 75 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import abc
4+
import hashlib
45
import io
56
import re
67
import shlex
@@ -108,6 +109,41 @@ async def _write_sensitive_config_file(
108109
)
109110

110111

112+
def _render_shell_exports(env_vars: list[tuple[str, str]]) -> bytes:
113+
lines = [f"export {name}={shlex.quote(value)}" for name, value in env_vars]
114+
return ("\n".join(lines) + "\n").encode("utf-8")
115+
116+
117+
def _redact_sensitive_values(text: str, sensitive_values: list[str]) -> str:
118+
redacted = text
119+
for value in sensitive_values:
120+
if not value:
121+
continue
122+
redacted = redacted.replace(value, "REDACTED")
123+
quoted = shlex.quote(value)
124+
if quoted != value:
125+
redacted = redacted.replace(quoted, "REDACTED")
126+
return redacted
127+
128+
129+
async def _read_text_if_present(session: BaseSandboxSession, path: Path) -> str:
130+
try:
131+
handle = await session.read(path)
132+
except Exception:
133+
return ""
134+
135+
try:
136+
raw = handle.read()
137+
finally:
138+
handle.close()
139+
140+
if isinstance(raw, bytes):
141+
return raw.decode("utf-8", errors="replace")
142+
if isinstance(raw, str):
143+
return raw
144+
return str(raw)
145+
146+
111147
class MountPatternBase(BaseModel, abc.ABC):
112148
@abc.abstractmethod
113149
async def apply(
@@ -425,25 +461,57 @@ async def apply(
425461
cmd.extend(["--prefix", mountpoint_config.prefix])
426462
cmd.extend([bucket, sandbox_path_str(path)])
427463

428-
env_parts: list[str] = []
464+
env_vars: list[tuple[str, str]] = []
429465
access_key_id = mountpoint_config.access_key_id
430466
secret_access_key = mountpoint_config.secret_access_key
431467
session_token = mountpoint_config.session_token
432468
if access_key_id and secret_access_key:
433-
env_parts.append(f"AWS_ACCESS_KEY_ID={shlex.quote(access_key_id)}")
434-
env_parts.append(f"AWS_SECRET_ACCESS_KEY={shlex.quote(secret_access_key)}")
469+
env_vars.append(("AWS_ACCESS_KEY_ID", access_key_id))
470+
env_vars.append(("AWS_SECRET_ACCESS_KEY", secret_access_key))
435471
if session_token:
436-
env_parts.append(f"AWS_SESSION_TOKEN={shlex.quote(session_token)}")
472+
env_vars.append(("AWS_SESSION_TOKEN", session_token))
437473

438474
joined_cmd = " ".join(shlex.quote(part) for part in cmd)
439-
if env_parts:
440-
joined_cmd = f"{' '.join(env_parts)} {joined_cmd}"
475+
stderr_path: Path | None = None
476+
sensitive_values = [value for _name, value in env_vars]
477+
if env_vars:
478+
session_id = getattr(session.state, "session_id", None)
479+
if session_id is None:
480+
raise MountConfigError(
481+
message="mount session is missing session_id",
482+
context={"type": mountpoint_config.mount_type},
483+
)
484+
command_hash = hashlib.sha256(
485+
f"{bucket}\0{sandbox_path_str(path)}".encode()
486+
).hexdigest()[:16]
487+
config_dir = posix_path_as_path(
488+
coerce_posix_path(f".sandbox-mountpoint-env/{session_id.hex}")
489+
)
490+
env_path = config_dir / f"{command_hash}.env"
491+
stdout_path = config_dir / f"{command_hash}.stdout"
492+
stderr_path = config_dir / f"{command_hash}.stderr"
493+
494+
await session.mkdir(config_dir, parents=True)
495+
session.register_persist_workspace_skip_path(config_dir)
496+
await _write_sensitive_config_file(session, env_path, _render_shell_exports(env_vars))
497+
498+
command_env_path = sandbox_path_str(session.normalize_path(env_path))
499+
command_stdout_path = sandbox_path_str(session.normalize_path(stdout_path))
500+
command_stderr_path = sandbox_path_str(session.normalize_path(stderr_path))
501+
joined_cmd = (
502+
f". {shlex.quote(command_env_path)} && exec {joined_cmd} "
503+
f">{shlex.quote(command_stdout_path)} 2>{shlex.quote(command_stderr_path)}"
504+
)
441505

442506
result = await session.exec("sh", "-lc", joined_cmd, shell=False)
443507
if not result.ok():
508+
stderr = result.stderr.decode("utf-8", errors="replace")
509+
if stderr_path is not None:
510+
stderr += await _read_text_if_present(session, stderr_path)
511+
stderr = _redact_sensitive_values(stderr, sensitive_values)
444512
raise MountCommandError(
445513
command=joined_cmd,
446-
stderr=result.stderr.decode("utf-8", errors="replace"),
514+
stderr=stderr,
447515
context={"bucket": bucket},
448516
)
449517

src/agents/sandbox/session/sandbox_session.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,9 @@ def _set_archive_limits(self, limits: SandboxArchiveLimits | None) -> None:
274274
def normalize_path(self, path: Path | str, *, for_write: bool = False) -> Path:
275275
return self._inner.normalize_path(path, for_write=for_write)
276276

277+
def register_persist_workspace_skip_path(self, path: Path | str) -> Path:
278+
return self._inner.register_persist_workspace_skip_path(path)
279+
277280
def supports_pty(self) -> bool:
278281
return self._inner.supports_pty()
279282

tests/sandbox/test_mounts.py

Lines changed: 112 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,12 @@
2929
RcloneMountConfig,
3030
S3FilesMountConfig,
3131
)
32-
from agents.sandbox.errors import MountConfigError
32+
from agents.sandbox.errors import MountCommandError, MountConfigError
3333
from agents.sandbox.session.base_sandbox_session import BaseSandboxSession
34+
from agents.sandbox.session.events import SandboxSessionEvent
35+
from agents.sandbox.session.manager import Instrumentation
36+
from agents.sandbox.session.sandbox_session import SandboxSession
37+
from agents.sandbox.session.sinks import CallbackSink
3438
from agents.sandbox.snapshot import NoopSnapshot
3539
from agents.sandbox.types import ExecResult
3640
from tests.utils.factories import TestSessionState
@@ -76,13 +80,16 @@ async def hydrate_workspace(self, data: io.IOBase) -> None:
7680

7781

7882
class _MountpointApplySession(BaseSandboxSession):
79-
def __init__(self) -> None:
83+
def __init__(self, *, mount_exit_code: int = 0, mount_stderr: bytes = b"") -> None:
8084
self.state = TestSessionState(
8185
session_id=uuid.uuid4(),
8286
manifest=Manifest(root="/workspace"),
8387
snapshot=NoopSnapshot(id=str(uuid.uuid4())),
8488
)
89+
self._mount_exit_code = mount_exit_code
90+
self._mount_stderr = mount_stderr
8591
self.exec_calls: list[list[str]] = []
92+
self.write_calls: list[tuple[Path, bytes]] = []
8693

8794
async def read(self, path: Path, *, user: object = None) -> io.BytesIO:
8895
_ = (path, user)
@@ -92,12 +99,15 @@ async def shutdown(self) -> None:
9299
return None
93100

94101
async def write(self, path: Path, data: io.IOBase, *, user: object = None) -> None:
95-
_ = (path, data, user)
96-
raise AssertionError("write() should not be called in these tests")
102+
_ = user
103+
self.write_calls.append((path, data.read()))
97104

98105
async def running(self) -> bool:
99106
return True
100107

108+
def persist_workspace_skip_paths(self) -> set[Path]:
109+
return self._persist_workspace_skip_relpaths()
110+
101111
async def _exec_internal(
102112
self,
103113
*command: str | Path,
@@ -106,6 +116,15 @@ async def _exec_internal(
106116
_ = timeout
107117
command_strs = [str(part) for part in command]
108118
self.exec_calls.append(command_strs)
119+
if (
120+
len(command_strs) >= 3
121+
and command_strs[:2] == ["sh", "-lc"]
122+
and "mount-s3 " in command_strs[2]
123+
and "command -v " not in command_strs[2]
124+
):
125+
return ExecResult(
126+
exit_code=self._mount_exit_code, stdout=b"", stderr=self._mount_stderr
127+
)
109128
return ExecResult(exit_code=0, stdout=b"", stderr=b"")
110129

111130
async def persist_workspace(self) -> io.IOBase:
@@ -380,15 +399,23 @@ async def test_gcs_mount_uses_runtime_endpoint_override_without_mutating_pattern
380399
["sh", "-lc", "command -v mount-s3 >/dev/null 2>&1"],
381400
["mkdir", "-p", "/workspace/remote"],
382401
]
383-
assert len(session.exec_calls) == 3
384-
385-
mount_command = session.exec_calls[2]
402+
assert len(session.exec_calls) == 5
403+
assert len(session.write_calls) == 1
404+
env_path, env_payload = session.write_calls[0]
405+
assert env_path.as_posix().startswith(".sandbox-mountpoint-env/")
406+
assert env_path.name.endswith(".env")
407+
assert env_payload == b"export AWS_ACCESS_KEY_ID=access\nexport AWS_SECRET_ACCESS_KEY=secret\n"
408+
409+
mount_command = session.exec_calls[-1]
386410
assert mount_command[:2] == ["sh", "-lc"]
387411
assert "mount-s3" in mount_command[2]
412+
assert "AWS_ACCESS_KEY_ID=access" not in mount_command[2]
413+
assert "AWS_SECRET_ACCESS_KEY=secret" not in mount_command[2]
414+
assert ".sandbox-mountpoint-env" in mount_command[2]
388415
assert "--region us-east1" in mount_command[2]
389416
assert "--endpoint-url https://storage.googleapis.com" in mount_command[2]
390417
assert "--upload-checksums off" in mount_command[2]
391-
assert mount_command[2].endswith("bucket /workspace/remote")
418+
assert "bucket /workspace/remote" in mount_command[2]
392419

393420

394421
@pytest.mark.asyncio
@@ -416,19 +443,29 @@ async def test_s3_mountpoint_writable_mode_enables_overwrite_and_delete() -> Non
416443
["sh", "-lc", "command -v mount-s3 >/dev/null 2>&1"],
417444
["mkdir", "-p", "/workspace/remote"],
418445
]
419-
assert len(session.exec_calls) == 3
420-
421-
mount_command = session.exec_calls[2]
446+
assert len(session.exec_calls) == 5
447+
assert len(session.write_calls) == 1
448+
env_path, env_payload = session.write_calls[0]
449+
assert env_path.as_posix().startswith(".sandbox-mountpoint-env/")
450+
assert env_path.name.endswith(".env")
451+
assert env_payload == (
452+
b"export AWS_ACCESS_KEY_ID=access\n"
453+
b"export AWS_SECRET_ACCESS_KEY=secret\n"
454+
b"export AWS_SESSION_TOKEN=token\n"
455+
)
456+
457+
mount_command = session.exec_calls[-1]
422458
assert mount_command[:2] == ["sh", "-lc"]
423459
assert "mount-s3" in mount_command[2]
424460
assert "--read-only" not in mount_command[2]
425461
assert "--allow-overwrite" in mount_command[2]
426462
assert "--allow-delete" in mount_command[2]
427463
assert "--region us-east-1" in mount_command[2]
428-
assert "AWS_ACCESS_KEY_ID=access" in mount_command[2]
429-
assert "AWS_SECRET_ACCESS_KEY=secret" in mount_command[2]
430-
assert "AWS_SESSION_TOKEN=token" in mount_command[2]
431-
assert mount_command[2].endswith("bucket /workspace/remote")
464+
assert "AWS_ACCESS_KEY_ID=access" not in mount_command[2]
465+
assert "AWS_SECRET_ACCESS_KEY=secret" not in mount_command[2]
466+
assert "AWS_SESSION_TOKEN=token" not in mount_command[2]
467+
assert ".sandbox-mountpoint-env" in mount_command[2]
468+
assert "bucket /workspace/remote" in mount_command[2]
432469

433470

434471
@pytest.mark.asyncio
@@ -456,9 +493,14 @@ async def test_gcs_mountpoint_writable_mode_enables_overwrite_and_delete() -> No
456493
["sh", "-lc", "command -v mount-s3 >/dev/null 2>&1"],
457494
["mkdir", "-p", "/workspace/remote"],
458495
]
459-
assert len(session.exec_calls) == 3
460-
461-
mount_command = session.exec_calls[2]
496+
assert len(session.exec_calls) == 5
497+
assert len(session.write_calls) == 1
498+
env_path, env_payload = session.write_calls[0]
499+
assert env_path.as_posix().startswith(".sandbox-mountpoint-env/")
500+
assert env_path.name.endswith(".env")
501+
assert env_payload == b"export AWS_ACCESS_KEY_ID=access\nexport AWS_SECRET_ACCESS_KEY=secret\n"
502+
503+
mount_command = session.exec_calls[-1]
462504
assert mount_command[:2] == ["sh", "-lc"]
463505
assert "mount-s3" in mount_command[2]
464506
assert "--read-only" not in mount_command[2]
@@ -467,9 +509,58 @@ async def test_gcs_mountpoint_writable_mode_enables_overwrite_and_delete() -> No
467509
assert "--region us-east1" in mount_command[2]
468510
assert "--endpoint-url https://storage.googleapis.com" in mount_command[2]
469511
assert "--upload-checksums off" in mount_command[2]
470-
assert "AWS_ACCESS_KEY_ID=access" in mount_command[2]
471-
assert "AWS_SECRET_ACCESS_KEY=secret" in mount_command[2]
472-
assert mount_command[2].endswith("bucket /workspace/remote")
512+
assert "AWS_ACCESS_KEY_ID=access" not in mount_command[2]
513+
assert "AWS_SECRET_ACCESS_KEY=secret" not in mount_command[2]
514+
assert ".sandbox-mountpoint-env" in mount_command[2]
515+
assert "bucket /workspace/remote" in mount_command[2]
516+
517+
518+
@pytest.mark.asyncio
519+
async def test_s3_mountpoint_failure_redacts_credentials_from_errors_and_events() -> None:
520+
events: list[SandboxSessionEvent] = []
521+
inner = _MountpointApplySession(
522+
mount_exit_code=1,
523+
mount_stderr=b"bad credentials: access secret token",
524+
)
525+
session = SandboxSession(
526+
inner,
527+
instrumentation=Instrumentation(
528+
sinks=[CallbackSink(lambda event, _session: events.append(event))]
529+
),
530+
)
531+
pattern = MountpointMountPattern()
532+
533+
with pytest.raises(MountCommandError) as exc_info:
534+
await pattern.apply(
535+
session,
536+
Path("/workspace/remote"),
537+
MountpointMountConfig(
538+
bucket="bucket",
539+
access_key_id="access",
540+
secret_access_key="secret",
541+
session_token="token",
542+
prefix=None,
543+
region="us-east-1",
544+
endpoint_url=None,
545+
mount_type="s3_mount",
546+
read_only=False,
547+
),
548+
)
549+
550+
context = exc_info.value.context
551+
command = str(context["command"])
552+
stderr = str(context["stderr"])
553+
assert "REDACTED" in stderr
554+
assert ".sandbox-mountpoint-env" in command
555+
assert any(
556+
path.as_posix().startswith(".sandbox-mountpoint-env/")
557+
for path in inner.persist_workspace_skip_paths()
558+
)
559+
serialized_events = "\n".join(event.model_dump_json() for event in events)
560+
for sensitive_value in ("access", "secret", "token"):
561+
assert sensitive_value not in command
562+
assert sensitive_value not in stderr
563+
assert sensitive_value not in serialized_events
473564

474565

475566
@pytest.mark.asyncio

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)