Skip to content

Commit c2d61da

Browse files
committed
more normalize_path replace
1 parent 926cd4c commit c2d61da

File tree

6 files changed

+176
-19
lines changed

6 files changed

+176
-19
lines changed

src/agents/extensions/sandbox/blaxel/sandbox.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -364,7 +364,7 @@ async def mkdir(
364364
if user is not None:
365365
path = await self._check_mkdir_with_exec(path, parents=parents, user=user)
366366
else:
367-
path = self.normalize_path(path, for_write=True)
367+
path = await self._validate_path_access(path, for_write=True)
368368
if path == Path("/"):
369369
return
370370
try:

src/agents/extensions/sandbox/cloudflare/sandbox.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -345,7 +345,7 @@ async def mount_bucket(
345345
mount_path: Path | str,
346346
options: dict[str, object],
347347
) -> None:
348-
workspace_path = self.normalize_path(mount_path)
348+
workspace_path = await self._validate_path_access(mount_path, for_write=True)
349349
http = self._session()
350350
url = self._url("mount")
351351
payload = {
@@ -389,7 +389,7 @@ async def mount_bucket(
389389
) from e
390390

391391
async def unmount_bucket(self, mount_path: Path | str) -> None:
392-
workspace_path = self.normalize_path(mount_path)
392+
workspace_path = await self._validate_path_access(mount_path, for_write=True)
393393
http = self._session()
394394
url = self._url("unmount")
395395
payload = {"mountPath": str(workspace_path)}

src/agents/extensions/sandbox/daytona/sandbox.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -371,7 +371,7 @@ async def mkdir(
371371
if user is not None:
372372
path = await self._check_mkdir_with_exec(path, parents=parents, user=user)
373373
else:
374-
path = self.normalize_path(path, for_write=True)
374+
path = await self._validate_path_access(path, for_write=True)
375375
if path == Path("/"):
376376
return
377377
try:

tests/extensions/test_sandbox_blaxel.py

Lines changed: 70 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,27 @@ def __init__(
6666
self.pid = pid
6767

6868

69+
def _fake_helper_exec_result(command: str, *, symlinks: dict[str, str]) -> _FakeExecResult | None:
70+
resolved = resolve_fake_workspace_path(
71+
command,
72+
symlinks=symlinks,
73+
home_dir="/workspace",
74+
)
75+
if resolved is not None:
76+
return _FakeExecResult(
77+
exit_code=resolved.exit_code,
78+
output=resolved.stdout,
79+
stderr=resolved.stderr,
80+
)
81+
82+
if "INSTALL_RUNTIME_HELPER_V1" in command or command.startswith(
83+
"test -x /tmp/openai-agents/bin/resolve-workspace-path-"
84+
):
85+
return _FakeExecResult()
86+
87+
return None
88+
89+
6990
class _FakeProcess:
7091
def __init__(self) -> None:
7192
self.exec_calls: list[tuple[dict[str, Any], dict[str, object]]] = []
@@ -76,17 +97,12 @@ def __init__(self) -> None:
7697

7798
async def exec(self, config: dict[str, Any], **kwargs: object) -> _FakeExecResult:
7899
self.exec_calls.append((config, dict(kwargs)))
79-
resolved = resolve_fake_workspace_path(
100+
helper_result = _fake_helper_exec_result(
80101
str(config.get("command", "")),
81102
symlinks=self.symlinks,
82-
home_dir="/workspace",
83103
)
84-
if resolved is not None:
85-
return _FakeExecResult(
86-
exit_code=resolved.exit_code,
87-
output=resolved.stdout,
88-
stderr=resolved.stderr,
89-
)
104+
if helper_result is not None:
105+
return helper_result
90106
if self.delay > 0:
91107
await asyncio.sleep(self.delay)
92108
if self._results_queue:
@@ -409,6 +425,29 @@ async def test_write_rejects_workspace_symlink_to_read_only_extra_path_grant(
409425
"resolved_path": "/tmp/protected/out.txt",
410426
}
411427

428+
@pytest.mark.asyncio
429+
async def test_mkdir_rejects_workspace_symlink_to_read_only_extra_path_grant(
430+
self,
431+
fake_sandbox: _FakeSandboxInstance,
432+
) -> None:
433+
state = _make_state(
434+
extra_path_grants=(SandboxPathGrant(path="/tmp/protected", read_only=True),)
435+
)
436+
session = _make_session(fake_sandbox, state=state)
437+
fake_sandbox.process.symlinks["/workspace/link"] = "/tmp/protected"
438+
439+
with pytest.raises(WorkspaceArchiveWriteError) as exc_info:
440+
await session.mkdir("link/newdir")
441+
442+
assert fake_sandbox.fs.mkdir_calls == []
443+
assert str(exc_info.value) == "failed to write archive for path: /workspace/link/newdir"
444+
assert exc_info.value.context == {
445+
"path": "/workspace/link/newdir",
446+
"reason": "read_only_extra_path_grant",
447+
"grant_path": "/tmp/protected",
448+
"resolved_path": "/tmp/protected/newdir",
449+
}
450+
412451
@pytest.mark.asyncio
413452
async def test_running(self, fake_sandbox: _FakeSandboxInstance) -> None:
414453
session = _make_session(fake_sandbox)
@@ -2270,11 +2309,17 @@ async def test_hydrate_cleanup_rm_failure_suppressed(
22702309
async def _counting_exec(config: dict[str, Any], **kw: object) -> _FakeExecResult:
22712310
nonlocal call_count
22722311
call_count += 1
2273-
if "tar" in config.get("command", ""):
2274-
if "xf" in config["command"]:
2312+
command = str(config.get("command", ""))
2313+
helper_result = _fake_helper_exec_result(
2314+
command, symlinks=fake_sandbox.process.symlinks
2315+
)
2316+
if helper_result is not None:
2317+
return helper_result
2318+
if "tar" in command:
2319+
if "xf" in command:
22752320
# tar extract succeeds.
22762321
return _FakeExecResult(exit_code=0, output="")
2277-
if "rm" in config.get("command", ""):
2322+
if "rm" in command:
22782323
raise ConnectionError("rm failed")
22792324
return _FakeExecResult(exit_code=0, output="")
22802325

@@ -2369,8 +2414,13 @@ async def test_persist_rm_exception_suppressed(
23692414
tar_data = _make_tar({"file.txt": b"hello"})
23702415

23712416
async def _exec_with_rm_fail(config: dict[str, Any], **kw: object) -> _FakeExecResult:
2372-
cmd = config.get("command", "")
2373-
if "rm" in cmd:
2417+
command = str(config.get("command", ""))
2418+
helper_result = _fake_helper_exec_result(
2419+
command, symlinks=fake_sandbox.process.symlinks
2420+
)
2421+
if helper_result is not None:
2422+
return helper_result
2423+
if "rm" in command:
23742424
raise OSError("rm failed")
23752425
return _FakeExecResult(exit_code=0, output="")
23762426

@@ -2390,8 +2440,13 @@ async def test_hydrate_rm_exception_suppressed(
23902440
tar_data = _make_tar({"file.txt": b"hello"})
23912441

23922442
async def _exec_with_rm_fail(config: dict[str, Any], **kw: object) -> _FakeExecResult:
2393-
cmd = config.get("command", "")
2394-
if "rm" in cmd:
2443+
command = str(config.get("command", ""))
2444+
helper_result = _fake_helper_exec_result(
2445+
command, symlinks=fake_sandbox.process.symlinks
2446+
)
2447+
if helper_result is not None:
2448+
return helper_result
2449+
if "rm" in command:
23952450
raise OSError("rm failed")
23962451
return _FakeExecResult(exit_code=0, output="")
23972452

tests/extensions/test_sandbox_cloudflare.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
MountConfigError,
3131
PtySessionNotFoundError,
3232
WorkspaceArchiveReadError,
33+
WorkspaceArchiveWriteError,
3334
WorkspaceReadNotFoundError,
3435
WorkspaceWriteTypeError,
3536
)
@@ -38,6 +39,7 @@
3839
from agents.sandbox.session.pty_types import PTY_PROCESSES_MAX, allocate_pty_process_id
3940
from agents.sandbox.snapshot import NoopSnapshot, SnapshotBase
4041
from agents.sandbox.types import ExecResult
42+
from agents.sandbox.workspace_paths import SandboxPathGrant
4143

4244
_WORKER_URL = "https://sandbox-cf.example.workers.dev"
4345

@@ -693,6 +695,70 @@ async def test_cloudflare_mount_and_unmount_bucket_use_http_endpoints() -> None:
693695
assert unmount_call["json"] == {"mountPath": "/workspace/data"}
694696

695697

698+
@pytest.mark.asyncio
699+
async def test_cloudflare_mount_and_unmount_validate_path_access_for_write() -> None:
700+
fake_http = _FakeHttp(
701+
{
702+
"POST /mount": _FakeResponse(status=200, json_body={"ok": True}),
703+
"POST /unmount": _FakeResponse(status=200, json_body={"ok": True}),
704+
}
705+
)
706+
sess = _make_session(fake_http=fake_http)
707+
calls: list[tuple[str, bool]] = []
708+
709+
async def _tracking_normalize(path: Path | str, *, for_write: bool = False) -> Path:
710+
calls.append((str(path), for_write))
711+
return sess.normalize_path(path, for_write=for_write)
712+
713+
sess._validate_path_access = _tracking_normalize # type: ignore[method-assign]
714+
715+
await sess.mount_bucket(
716+
bucket="my-bucket",
717+
mount_path=Path("/workspace/data"),
718+
options={
719+
"endpoint": "https://s3.amazonaws.com",
720+
"readOnly": True,
721+
},
722+
)
723+
await sess.unmount_bucket(Path("/workspace/data"))
724+
725+
assert calls == [
726+
("/workspace/data", True),
727+
("/workspace/data", True),
728+
]
729+
730+
731+
@pytest.mark.asyncio
732+
async def test_cloudflare_mount_rejects_read_only_extra_path_grant() -> None:
733+
fake_http = _FakeHttp({"POST /mount": _FakeResponse(status=200, json_body={"ok": True})})
734+
sess = _make_session(
735+
state=_make_state(
736+
manifest=Manifest(
737+
extra_path_grants=(SandboxPathGrant(path="/tmp/protected", read_only=True),)
738+
)
739+
),
740+
fake_http=fake_http,
741+
)
742+
743+
with pytest.raises(WorkspaceArchiveWriteError) as exc_info:
744+
await sess.mount_bucket(
745+
bucket="my-bucket",
746+
mount_path=Path("/tmp/protected/data"),
747+
options={
748+
"endpoint": "https://s3.amazonaws.com",
749+
"readOnly": True,
750+
},
751+
)
752+
753+
assert fake_http.calls == []
754+
assert str(exc_info.value) == "failed to write archive for path: /tmp/protected/data"
755+
assert exc_info.value.context == {
756+
"path": "/tmp/protected/data",
757+
"reason": "read_only_extra_path_grant",
758+
"grant_path": "/tmp/protected",
759+
}
760+
761+
696762
async def test_cloudflare_read_decodes_streamed_file_payload() -> None:
697763
sess = _make_session(
698764
fake_http=_FakeHttp(

tests/extensions/test_sandbox_daytona.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -933,6 +933,42 @@ async def test_write_rejects_workspace_symlink_to_read_only_extra_path_grant(
933933
"resolved_path": "/tmp/protected/out.txt",
934934
}
935935

936+
@pytest.mark.asyncio
937+
async def test_mkdir_rejects_workspace_symlink_to_read_only_extra_path_grant(
938+
self,
939+
monkeypatch: pytest.MonkeyPatch,
940+
) -> None:
941+
daytona_module = _load_daytona_module(monkeypatch)
942+
943+
async with daytona_module.DaytonaSandboxClient() as client:
944+
session = await client.create(
945+
manifest=Manifest(
946+
root=daytona_module.DEFAULT_DAYTONA_WORKSPACE_ROOT,
947+
extra_path_grants=(SandboxPathGrant(path="/tmp/protected", read_only=True),),
948+
),
949+
options=daytona_module.DaytonaSandboxClientOptions(),
950+
)
951+
sandbox = _FakeAsyncDaytona.current_sandbox
952+
assert sandbox is not None
953+
sandbox.process.symlinks[f"{daytona_module.DEFAULT_DAYTONA_WORKSPACE_ROOT}/link"] = (
954+
"/tmp/protected"
955+
)
956+
957+
with pytest.raises(daytona_module.WorkspaceArchiveWriteError) as exc_info:
958+
await session.mkdir("link/newdir")
959+
960+
assert sandbox.fs.create_folder_calls == []
961+
assert str(exc_info.value) == (
962+
"failed to write archive for path: "
963+
f"{daytona_module.DEFAULT_DAYTONA_WORKSPACE_ROOT}/link/newdir"
964+
)
965+
assert exc_info.value.context == {
966+
"path": f"{daytona_module.DEFAULT_DAYTONA_WORKSPACE_ROOT}/link/newdir",
967+
"reason": "read_only_extra_path_grant",
968+
"grant_path": "/tmp/protected",
969+
"resolved_path": "/tmp/protected/newdir",
970+
}
971+
936972
@pytest.mark.asyncio
937973
async def test_mkdir_as_user_checks_permissions_then_uses_files_api(
938974
self,

0 commit comments

Comments
 (0)