Skip to content

Commit a7a8d6d

Browse files
committed
address comment
1 parent b60142e commit a7a8d6d

2 files changed

Lines changed: 130 additions & 5 deletions

File tree

src/agents/extensions/sandbox/runloop/sandbox.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
from ....sandbox.session.base_sandbox_session import BaseSandboxSession
5050
from ....sandbox.session.dependencies import Dependencies
5151
from ....sandbox.session.manager import Instrumentation
52+
from ....sandbox.session.runtime_helpers import RESOLVE_WORKSPACE_PATH_HELPER, RuntimeHelperScript
5253
from ....sandbox.session.sandbox_client import BaseSandboxClient, BaseSandboxClientOptions
5354
from ....sandbox.snapshot import SnapshotBase, SnapshotSpec, resolve_snapshot
5455
from ....sandbox.types import ExecResult, ExposedPortEndpoint, User
@@ -706,6 +707,12 @@ async def shutdown(self) -> None:
706707
def supports_pty(self) -> bool:
707708
return False
708709

710+
async def _validate_path_access(self, path: Path | str, *, for_write: bool = False) -> Path:
711+
return await self._validate_remote_path_access(path, for_write=for_write)
712+
713+
def _runtime_helpers(self) -> tuple[RuntimeHelperScript, ...]:
714+
return (RESOLVE_WORKSPACE_PATH_HELPER,)
715+
709716
async def _wrap_command_in_workspace_context(self, command: str) -> str:
710717
root_q = shlex.quote(self.state.manifest.root)
711718
envs = await self._resolved_envs()
@@ -867,7 +874,7 @@ async def read(self, path: Path | str, *, user: str | User | None = None) -> io.
867874
if user is not None:
868875
await self._check_read_with_exec(path, user=user)
869876

870-
normalized_path = self.normalize_path(path)
877+
normalized_path = await self._validate_path_access(path)
871878
try:
872879
payload = await self._devbox.file.download(
873880
path=str(normalized_path),
@@ -907,7 +914,7 @@ async def write(
907914
if not isinstance(payload, bytes | bytearray):
908915
raise WorkspaceWriteTypeError(path=path, actual_type=type(payload).__name__)
909916

910-
workspace_path = self.normalize_path(path, for_write=True)
917+
workspace_path = await self._validate_path_access(path, for_write=True)
911918
await self.mkdir(workspace_path.parent, parents=True)
912919
try:
913920
await self._devbox.file.upload(
@@ -950,7 +957,7 @@ async def mkdir(
950957
if user is not None:
951958
path = await self._check_mkdir_with_exec(path, parents=parents, user=user)
952959
else:
953-
path = self.normalize_path(path, for_write=True)
960+
path = await self._validate_path_access(path, for_write=True)
954961
cmd = ["mkdir"]
955962
if parents:
956963
cmd.append("-p")

tests/extensions/test_sandbox_runloop.py

Lines changed: 120 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,9 @@ def __init__(
237237
self._apply_tar_extract()
238238
self._exit_code = 0
239239
self._done.set()
240+
elif self._is_resolve_workspace_path_command(command):
241+
self._resolve_workspace_path(command)
242+
self._done.set()
240243
elif " cat -- " in command or command.startswith("cat -- "):
241244
self._stdout = self._read_file_text(command)
242245
self._emit(stdout_cb, self._stdout)
@@ -275,6 +278,86 @@ def _path_relative_to_home(self, raw_path: str) -> str:
275278
rel_str = relative.as_posix()
276279
return rel_str if rel_str else "."
277280

281+
def _is_resolve_workspace_path_command(self, command: str) -> bool:
282+
tokens = shlex.split(command)
283+
return any(
284+
token.startswith("/tmp/openai-agents/bin/resolve-workspace-path-")
285+
and len(tokens) >= index + 4
286+
for index, token in enumerate(tokens)
287+
)
288+
289+
def _resolve_fake_path(self, raw_path: str, *, depth: int = 0) -> PurePosixPath:
290+
if depth > 64:
291+
raise RuntimeError(f"symlink resolution depth exceeded: {raw_path}")
292+
293+
path = PurePosixPath(raw_path)
294+
if not path.is_absolute():
295+
path = PurePosixPath(self._home_dir) / path
296+
297+
parts = path.parts
298+
current = PurePosixPath("/")
299+
for index, part in enumerate(parts[1:], start=1):
300+
current = current / part
301+
target = self._devbox.symlinks.get(current.as_posix())
302+
if target is None:
303+
continue
304+
305+
target_path = PurePosixPath(target)
306+
if not target_path.is_absolute():
307+
target_path = current.parent / target_path
308+
for remaining in parts[index + 1 :]:
309+
target_path /= remaining
310+
return self._resolve_fake_path(target_path.as_posix(), depth=depth + 1)
311+
312+
return path
313+
314+
@staticmethod
315+
def _fake_path_is_under(path: PurePosixPath, root: PurePosixPath) -> bool:
316+
return path == root or root in path.parents
317+
318+
def _resolve_workspace_path(self, command: str) -> None:
319+
tokens = self._command_tokens()
320+
helper_index = next(
321+
index
322+
for index, token in enumerate(tokens)
323+
if token.startswith("/tmp/openai-agents/bin/resolve-workspace-path-")
324+
)
325+
root = self._resolve_fake_path(tokens[helper_index + 1])
326+
candidate = self._resolve_fake_path(tokens[helper_index + 2])
327+
for_write = tokens[helper_index + 3]
328+
grant_tokens = tokens[helper_index + 4 :]
329+
330+
if self._fake_path_is_under(candidate, root):
331+
self._stdout = f"{candidate.as_posix()}\n"
332+
self._exit_code = 0
333+
return
334+
335+
best_grant: tuple[PurePosixPath, str, str] | None = None
336+
for index in range(0, len(grant_tokens), 2):
337+
grant_original = grant_tokens[index]
338+
read_only = grant_tokens[index + 1]
339+
grant_root = self._resolve_fake_path(grant_original)
340+
if not self._fake_path_is_under(candidate, grant_root):
341+
continue
342+
if best_grant is None or len(grant_root.parts) > len(best_grant[0].parts):
343+
best_grant = (grant_root, grant_original, read_only)
344+
345+
if best_grant is not None:
346+
_grant_root, grant_original, read_only = best_grant
347+
if for_write == "1" and read_only == "1":
348+
self._stderr = (
349+
f"read-only extra path grant: {grant_original}\n"
350+
f"resolved path: {candidate.as_posix()}\n"
351+
)
352+
self._exit_code = 114
353+
return
354+
self._stdout = f"{candidate.as_posix()}\n"
355+
self._exit_code = 0
356+
return
357+
358+
self._stderr = f"workspace escape: {candidate.as_posix()}\n"
359+
self._exit_code = 111
360+
278361
def _apply_tar_extract(self) -> None:
279362
tokens = self._command_tokens()
280363
tar_index = tokens.index("tar")
@@ -469,6 +552,7 @@ def __init__(
469552
else:
470553
self.home_dir = "/home/user"
471554
self.files: dict[str, bytes] = {}
555+
self.symlinks: dict[str, str] = {}
472556
self.file_download_paths: list[str] = []
473557
self.file_upload_paths: list[str] = []
474558
self.tunnel_key: str | None = None
@@ -2334,8 +2418,42 @@ async def test_read_and_write_extra_path_grant_use_file_api_directly(
23342418
assert devbox.files["/tmp/output.txt"] == b"hello"
23352419
assert devbox.file_upload_paths == ["/tmp/output.txt"]
23362420
assert devbox.file_download_paths == ["/tmp/output.txt"]
2337-
assert len(devbox.exec_calls) == exec_count + 1
2338-
assert "mkdir -p -- /tmp" in devbox.exec_calls[-1][0]
2421+
assert len(devbox.exec_calls) == exec_count + 7
2422+
assert devbox.exec_calls[exec_count + 4][0] == "mkdir -p -- /tmp"
2423+
2424+
@pytest.mark.asyncio
2425+
async def test_write_rejects_workspace_symlink_to_read_only_extra_path_grant(
2426+
self,
2427+
monkeypatch: pytest.MonkeyPatch,
2428+
) -> None:
2429+
runloop_module = _load_runloop_module(monkeypatch)
2430+
2431+
async with runloop_module.RunloopSandboxClient() as client:
2432+
session = await client.create(
2433+
manifest=Manifest(
2434+
root="/home/user/project",
2435+
extra_path_grants=(SandboxPathGrant(path="/tmp/protected", read_only=True),),
2436+
),
2437+
options=runloop_module.RunloopSandboxClientOptions(),
2438+
)
2439+
await session.start()
2440+
sdk = _FakeAsyncRunloopSDK.created_instances[-1]
2441+
devbox = sdk.devbox.devboxes[session.state.devbox_id]
2442+
devbox.symlinks["/home/user/project/link"] = "/tmp/protected"
2443+
2444+
with pytest.raises(runloop_module.WorkspaceArchiveWriteError) as exc_info:
2445+
await session.write("link/result.txt", io.BytesIO(b"blocked"))
2446+
2447+
assert devbox.file_upload_paths == []
2448+
assert str(exc_info.value) == (
2449+
"failed to write archive for path: /home/user/project/link/result.txt"
2450+
)
2451+
assert exc_info.value.context == {
2452+
"path": "/home/user/project/link/result.txt",
2453+
"reason": "read_only_extra_path_grant",
2454+
"grant_path": "/tmp/protected",
2455+
"resolved_path": "/tmp/protected/result.txt",
2456+
}
23392457

23402458
@pytest.mark.asyncio
23412459
async def test_read_wraps_runloop_http_error_with_provider_context(

0 commit comments

Comments
 (0)