Skip to content

Commit 926cd4c

Browse files
committed
address comments
1 parent 44ada78 commit 926cd4c

File tree

6 files changed

+86
-6
lines changed

6 files changed

+86
-6
lines changed

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -379,9 +379,10 @@ async def mkdir(
379379
async def read(self, path: Path | str, *, user: str | User | None = None) -> io.IOBase:
380380
path = Path(path)
381381
if user is not None:
382-
await self._check_read_with_exec(path, user=user)
382+
workspace_path = await self._check_read_with_exec(path, user=user)
383+
else:
384+
workspace_path = await self._validate_path_access(path)
383385

384-
workspace_path = self.normalize_path(path)
385386
try:
386387
data: Any = await self._sandbox.fs.read_binary(str(workspace_path))
387388
if isinstance(data, str):

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -784,9 +784,10 @@ async def _terminate_pty_entry(self, entry: _DaytonaPtySessionEntry) -> None:
784784
async def read(self, path: Path | str, *, user: str | User | None = None) -> io.IOBase:
785785
path = Path(path)
786786
if user is not None:
787-
await self._check_read_with_exec(path, user=user)
787+
workspace_path = await self._check_read_with_exec(path, user=user)
788+
else:
789+
workspace_path = await self._validate_path_access(path)
788790

789-
workspace_path = self.normalize_path(path)
790791
daytona_exc = _import_daytona_exceptions()
791792
not_found_exc = daytona_exc.get("not_found")
792793

src/agents/extensions/sandbox/vercel/sandbox.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -563,8 +563,8 @@ async def read(self, path: Path, *, user: str | User | None = None) -> io.IOBase
563563
if user is not None:
564564
self._reject_user_arg(op="read", user=user)
565565

566+
normalized_path = await self._validate_path_access(path)
566567
sandbox = await self._ensure_sandbox()
567-
normalized_path = self.normalize_path(path)
568568
try:
569569
payload = await sandbox.read_file(str(normalized_path))
570570
except Exception as exc:

tests/extensions/test_sandbox_blaxel.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ def __init__(self) -> None:
105105
self.write_error: Exception | None = None
106106
self.mkdir_error: Exception | None = None
107107
self.return_str: bool = False
108+
self.read_binary_calls: list[str] = []
108109
self.write_binary_calls: list[tuple[str, bytes]] = []
109110

110111
async def mkdir(self, path: str, permissions: str = "0755") -> None:
@@ -114,6 +115,7 @@ async def mkdir(self, path: str, permissions: str = "0755") -> None:
114115
self.dirs.append(path)
115116

116117
async def read_binary(self, path: str) -> bytes | str:
118+
self.read_binary_calls.append(path)
117119
if self.read_error is not None:
118120
raise self.read_error
119121
if path not in self.files:
@@ -359,6 +361,25 @@ async def test_read_not_found(self, fake_sandbox: _FakeSandboxInstance) -> None:
359361
with pytest.raises(WorkspaceReadNotFoundError):
360362
await session.read("nonexistent.txt")
361363

364+
@pytest.mark.asyncio
365+
async def test_read_rejects_workspace_symlink_to_ungranted_path(
366+
self,
367+
fake_sandbox: _FakeSandboxInstance,
368+
) -> None:
369+
session = _make_session(fake_sandbox)
370+
fake_sandbox.process.symlinks["/workspace/link"] = "/private"
371+
372+
with pytest.raises(InvalidManifestPathError) as exc_info:
373+
await session.read("link/secret.txt")
374+
375+
assert fake_sandbox.fs.read_binary_calls == []
376+
assert str(exc_info.value) == "manifest path must not escape root: link/secret.txt"
377+
assert exc_info.value.context == {
378+
"rel": "link/secret.txt",
379+
"reason": "escape_root",
380+
"resolved_path": "workspace escape: /private/secret.txt",
381+
}
382+
362383
@pytest.mark.asyncio
363384
async def test_write(self, fake_sandbox: _FakeSandboxInstance) -> None:
364385
session = _make_session(fake_sandbox)

tests/extensions/test_sandbox_daytona.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,14 +199,15 @@ async def delete_session(self, session_id: str) -> None:
199199
class _FakeFs:
200200
def __init__(self) -> None:
201201
self.create_folder_calls: list[tuple[str, str]] = []
202+
self.download_file_calls: list[tuple[str, float | None]] = []
202203
self.upload_file_calls: list[tuple[bytes, str, float | None]] = []
203204
self.download_value: bytes = b""
204205

205206
async def create_folder(self, path: str, mode: str) -> None:
206207
self.create_folder_calls.append((path, mode))
207208

208209
async def download_file(self, path: str, timeout: float | None = None) -> bytes:
209-
_ = (path, timeout)
210+
self.download_file_calls.append((path, timeout))
210211
return self.download_value
211212

212213
async def upload_file(self, data: bytes, path: str, *, timeout: float | None = None) -> None:
@@ -870,6 +871,32 @@ async def test_read_and_write_reject_paths_outside_workspace_root(
870871
with pytest.raises(daytona_module.InvalidManifestPathError):
871872
await session.write("/etc/passwd", io.BytesIO(b"nope"))
872873

874+
@pytest.mark.asyncio
875+
async def test_read_rejects_workspace_symlink_to_ungranted_path(
876+
self,
877+
monkeypatch: pytest.MonkeyPatch,
878+
) -> None:
879+
daytona_module = _load_daytona_module(monkeypatch)
880+
881+
async with daytona_module.DaytonaSandboxClient() as client:
882+
session = await client.create(options=daytona_module.DaytonaSandboxClientOptions())
883+
sandbox = _FakeAsyncDaytona.current_sandbox
884+
assert sandbox is not None
885+
sandbox.process.symlinks[f"{daytona_module.DEFAULT_DAYTONA_WORKSPACE_ROOT}/link"] = (
886+
"/private"
887+
)
888+
889+
with pytest.raises(daytona_module.InvalidManifestPathError) as exc_info:
890+
await session.read("link/secret.txt")
891+
892+
assert sandbox.fs.download_file_calls == []
893+
assert str(exc_info.value) == "manifest path must not escape root: link/secret.txt"
894+
assert exc_info.value.context == {
895+
"rel": "link/secret.txt",
896+
"reason": "escape_root",
897+
"resolved_path": "workspace escape: /private/secret.txt",
898+
}
899+
873900
@pytest.mark.asyncio
874901
async def test_write_rejects_workspace_symlink_to_read_only_extra_path_grant(
875902
self,

tests/extensions/test_sandbox_vercel.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ def __init__(
129129
self.next_command_result = _FakeCommandFinished()
130130
self.run_command_calls: list[tuple[str, list[str], str | None]] = []
131131
self.refresh_calls = 0
132+
self.read_file_calls: list[tuple[str, str | None]] = []
132133
self.stop_calls = 0
133134
self.wait_for_status_calls: list[tuple[object, float | None]] = []
134135
self.wait_for_status_error: BaseException | None = None
@@ -266,6 +267,7 @@ async def run_command(
266267
return self.next_command_result
267268

268269
async def read_file(self, path: str, *, cwd: str | None = None) -> bytes | None:
270+
self.read_file_calls.append((path, cwd))
269271
resolved = path if path.startswith("/") or cwd is None else f"{cwd.rstrip('/')}/{path}"
270272
return self.files.get(resolved)
271273

@@ -682,6 +684,34 @@ async def test_vercel_read_and_write_reject_paths_outside_workspace_root(
682684
await session.write("/etc/passwd", io.BytesIO(b"nope"))
683685

684686

687+
@pytest.mark.asyncio
688+
async def test_vercel_read_rejects_workspace_symlink_to_ungranted_path(
689+
monkeypatch: pytest.MonkeyPatch,
690+
) -> None:
691+
vercel_module = _load_vercel_module(monkeypatch)
692+
693+
state = vercel_module.VercelSandboxSessionState(
694+
session_id="00000000-0000-0000-0000-000000000016",
695+
manifest=Manifest(root="/workspace"),
696+
snapshot=NoopSnapshot(id="snapshot"),
697+
sandbox_id="sandbox-read-escape-link",
698+
)
699+
sandbox = _FakeAsyncSandbox(sandbox_id="sandbox-read-escape-link")
700+
sandbox.symlinks["/workspace/link"] = "/private"
701+
session = vercel_module.VercelSandboxSession.from_state(state, sandbox=sandbox)
702+
703+
with pytest.raises(InvalidManifestPathError) as exc_info:
704+
await session.read("link/secret.txt")
705+
706+
assert sandbox.read_file_calls == []
707+
assert str(exc_info.value) == "manifest path must not escape root: link/secret.txt"
708+
assert exc_info.value.context == {
709+
"rel": "link/secret.txt",
710+
"reason": "escape_root",
711+
"resolved_path": "workspace escape: /private/secret.txt",
712+
}
713+
714+
685715
@pytest.mark.asyncio
686716
async def test_vercel_write_rejects_workspace_symlink_to_read_only_extra_path_grant(
687717
monkeypatch: pytest.MonkeyPatch,

0 commit comments

Comments
 (0)