豆豆友情提示:这是一个非官方 GitHub 代理镜像,主要用于网络测试或访问加速。请勿在此进行登录、注册或处理任何敏感信息。进行这些操作请务必访问官方网站 github.com。 Raw 内容也通过此代理提供。
Skip to content

Commit 44ada78

Browse files
committed
use remote access validator for sandboxes
1 parent b3ed127 commit 44ada78

File tree

7 files changed

+296
-14
lines changed

7 files changed

+296
-14
lines changed

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
resolve_pty_write_yield_time_ms,
5555
truncate_text_by_tokens,
5656
)
57+
from ....sandbox.session.runtime_helpers import RESOLVE_WORKSPACE_PATH_HELPER, RuntimeHelperScript
5758
from ....sandbox.session.sandbox_client import BaseSandboxClient
5859
from ....sandbox.snapshot import SnapshotBase, SnapshotSpec, resolve_snapshot
5960
from ....sandbox.types import ExecResult, ExposedPortEndpoint, User
@@ -345,6 +346,12 @@ async def shutdown(self) -> None:
345346
except Exception as e:
346347
logger.warning("sandbox delete failed during shutdown: %s", e)
347348

349+
async def _validate_path_access(self, path: Path | str, *, for_write: bool = False) -> Path:
350+
return await self._validate_remote_path_access(path, for_write=for_write)
351+
352+
def _runtime_helpers(self) -> tuple[RuntimeHelperScript, ...]:
353+
return (RESOLVE_WORKSPACE_PATH_HELPER,)
354+
348355
# -- file operations -----------------------------------------------------
349356

350357
async def mkdir(
@@ -409,7 +416,7 @@ async def write(
409416
if not isinstance(payload, bytes | bytearray):
410417
raise WorkspaceWriteTypeError(path=path, actual_type=type(payload).__name__)
411418

412-
workspace_path = self.normalize_path(path, for_write=True)
419+
workspace_path = await self._validate_path_access(path, for_write=True)
413420
try:
414421
await self._sandbox.fs.write_binary(str(workspace_path), bytes(payload))
415422
except Exception as e:

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
resolve_pty_write_yield_time_ms,
5353
truncate_text_by_tokens,
5454
)
55+
from ....sandbox.session.runtime_helpers import RESOLVE_WORKSPACE_PATH_HELPER, RuntimeHelperScript
5556
from ....sandbox.session.sandbox_client import BaseSandboxClient, BaseSandboxClientOptions
5657
from ....sandbox.snapshot import SnapshotBase, SnapshotSpec, resolve_snapshot
5758
from ....sandbox.types import ExecResult, ExposedPortEndpoint, User
@@ -354,6 +355,12 @@ async def _shutdown_backend(self) -> None:
354355
except Exception:
355356
pass
356357

358+
async def _validate_path_access(self, path: Path | str, *, for_write: bool = False) -> Path:
359+
return await self._validate_remote_path_access(path, for_write=for_write)
360+
361+
def _runtime_helpers(self) -> tuple[RuntimeHelperScript, ...]:
362+
return (RESOLVE_WORKSPACE_PATH_HELPER,)
363+
357364
async def mkdir(
358365
self,
359366
path: Path | str,
@@ -811,7 +818,7 @@ async def write(
811818
if not isinstance(payload, bytes | bytearray):
812819
raise WorkspaceWriteTypeError(path=path, actual_type=type(payload).__name__)
813820

814-
workspace_path = self.normalize_path(path, for_write=True)
821+
workspace_path = await self._validate_path_access(path, for_write=True)
815822
try:
816823
await self._sandbox.fs.upload_file(
817824
bytes(payload),

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
from ....sandbox.session.base_sandbox_session import BaseSandboxSession
5151
from ....sandbox.session.dependencies import Dependencies
5252
from ....sandbox.session.manager import Instrumentation
53+
from ....sandbox.session.runtime_helpers import RESOLVE_WORKSPACE_PATH_HELPER, RuntimeHelperScript
5354
from ....sandbox.session.sandbox_client import BaseSandboxClient, BaseSandboxClientOptions
5455
from ....sandbox.snapshot import SnapshotBase, SnapshotSpec, resolve_snapshot
5556
from ....sandbox.types import ExecResult, ExposedPortEndpoint, User
@@ -277,6 +278,12 @@ def _prepare_exec_command(
277278
self._reject_user_arg(op="exec", user=user)
278279
return super()._prepare_exec_command(*command, shell=shell, user=user)
279280

281+
async def _validate_path_access(self, path: Path | str, *, for_write: bool = False) -> Path:
282+
return await self._validate_remote_path_access(path, for_write=for_write)
283+
284+
def _runtime_helpers(self) -> tuple[RuntimeHelperScript, ...]:
285+
return (RESOLVE_WORKSPACE_PATH_HELPER,)
286+
280287
def _validate_tar_bytes(self, raw: bytes) -> None:
281288
try:
282289
with tarfile.open(fileobj=io.BytesIO(raw), mode="r:*") as tar:
@@ -576,7 +583,7 @@ async def write(
576583
if user is not None:
577584
self._reject_user_arg(op="write", user=user)
578585

579-
normalized_path = self.normalize_path(path, for_write=True)
586+
normalized_path = await self._validate_path_access(path, for_write=True)
580587
payload = data.read()
581588
if isinstance(payload, str):
582589
payload = payload.encode("utf-8")

tests/_fake_workspace_paths.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
from __future__ import annotations
2+
3+
import shlex
4+
from collections.abc import Sequence
5+
from dataclasses import dataclass
6+
from pathlib import PurePosixPath
7+
8+
9+
@dataclass(frozen=True)
10+
class FakeResolveWorkspaceResult:
11+
exit_code: int
12+
stdout: str = ""
13+
stderr: str = ""
14+
15+
16+
def resolve_fake_workspace_path(
17+
command: str | Sequence[str],
18+
*,
19+
symlinks: dict[str, str],
20+
home_dir: str,
21+
) -> FakeResolveWorkspaceResult | None:
22+
tokens = shlex.split(command) if isinstance(command, str) else list(command)
23+
helper_index = next(
24+
(
25+
index
26+
for index, token in enumerate(tokens)
27+
if token.startswith("/tmp/openai-agents/bin/resolve-workspace-path-")
28+
),
29+
None,
30+
)
31+
if helper_index is None or len(tokens) < helper_index + 4:
32+
return None
33+
34+
root = _resolve_fake_path(tokens[helper_index + 1], symlinks=symlinks, home_dir=home_dir)
35+
candidate = _resolve_fake_path(tokens[helper_index + 2], symlinks=symlinks, home_dir=home_dir)
36+
for_write = tokens[helper_index + 3]
37+
grant_tokens = tokens[helper_index + 4 :]
38+
39+
if _fake_path_is_under(candidate, root):
40+
return FakeResolveWorkspaceResult(exit_code=0, stdout=candidate.as_posix())
41+
42+
best_grant: tuple[PurePosixPath, str, str] | None = None
43+
for index in range(0, len(grant_tokens), 2):
44+
grant_original = grant_tokens[index]
45+
read_only = grant_tokens[index + 1]
46+
grant_root = _resolve_fake_path(grant_original, symlinks=symlinks, home_dir=home_dir)
47+
if not _fake_path_is_under(candidate, grant_root):
48+
continue
49+
if best_grant is None or len(grant_root.parts) > len(best_grant[0].parts):
50+
best_grant = (grant_root, grant_original, read_only)
51+
52+
if best_grant is not None:
53+
_grant_root, grant_original, read_only = best_grant
54+
if for_write == "1" and read_only == "1":
55+
return FakeResolveWorkspaceResult(
56+
exit_code=114,
57+
stderr=(
58+
f"read-only extra path grant: {grant_original}\n"
59+
f"resolved path: {candidate.as_posix()}\n"
60+
),
61+
)
62+
return FakeResolveWorkspaceResult(exit_code=0, stdout=candidate.as_posix())
63+
64+
return FakeResolveWorkspaceResult(
65+
exit_code=111,
66+
stderr=f"workspace escape: {candidate.as_posix()}\n",
67+
)
68+
69+
70+
def _resolve_fake_path(
71+
raw_path: str,
72+
*,
73+
symlinks: dict[str, str],
74+
home_dir: str,
75+
depth: int = 0,
76+
) -> PurePosixPath:
77+
if depth > 64:
78+
raise RuntimeError(f"symlink resolution depth exceeded: {raw_path}")
79+
80+
path = PurePosixPath(raw_path)
81+
if not path.is_absolute():
82+
path = PurePosixPath(home_dir) / path
83+
84+
parts = path.parts
85+
current = PurePosixPath("/")
86+
for index, part in enumerate(parts[1:], start=1):
87+
current = current / part
88+
target = symlinks.get(current.as_posix())
89+
if target is None:
90+
continue
91+
92+
target_path = PurePosixPath(target)
93+
if not target_path.is_absolute():
94+
target_path = current.parent / target_path
95+
for remaining in parts[index + 1 :]:
96+
target_path /= remaining
97+
return _resolve_fake_path(
98+
target_path.as_posix(),
99+
symlinks=symlinks,
100+
home_dir=home_dir,
101+
depth=depth + 1,
102+
)
103+
104+
return path
105+
106+
107+
def _fake_path_is_under(path: PurePosixPath, root: PurePosixPath) -> bool:
108+
return path == root or root in path.parents

tests/extensions/test_sandbox_blaxel.py

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
import pytest
1515
from pydantic import ValidationError
1616

17-
from agents.sandbox import Manifest
17+
from agents.sandbox import Manifest, SandboxPathGrant
1818
from agents.sandbox.config import DEFAULT_PYTHON_SANDBOX_IMAGE
1919
from agents.sandbox.errors import (
2020
ExecTimeoutError,
@@ -29,6 +29,7 @@
2929
from agents.sandbox.snapshot import NoopSnapshot
3030
from agents.sandbox.types import ExposedPortEndpoint
3131
from agents.sandbox.util.tar_utils import validate_tar_bytes
32+
from tests._fake_workspace_paths import resolve_fake_workspace_path
3233

3334
# ---------------------------------------------------------------------------
3435
# Package re-export test
@@ -71,9 +72,21 @@ def __init__(self) -> None:
7172
self.next_result = _FakeExecResult()
7273
self._results_queue: list[_FakeExecResult] = []
7374
self.delay: float = 0.0
75+
self.symlinks: dict[str, str] = {}
7476

7577
async def exec(self, config: dict[str, Any], **kwargs: object) -> _FakeExecResult:
7678
self.exec_calls.append((config, dict(kwargs)))
79+
resolved = resolve_fake_workspace_path(
80+
str(config.get("command", "")),
81+
symlinks=self.symlinks,
82+
home_dir="/workspace",
83+
)
84+
if resolved is not None:
85+
return _FakeExecResult(
86+
exit_code=resolved.exit_code,
87+
output=resolved.stdout,
88+
stderr=resolved.stderr,
89+
)
7790
if self.delay > 0:
7891
await asyncio.sleep(self.delay)
7992
if self._results_queue:
@@ -92,6 +105,7 @@ def __init__(self) -> None:
92105
self.write_error: Exception | None = None
93106
self.mkdir_error: Exception | None = None
94107
self.return_str: bool = False
108+
self.write_binary_calls: list[tuple[str, bytes]] = []
95109

96110
async def mkdir(self, path: str, permissions: str = "0755") -> None:
97111
self.mkdir_calls.append(path)
@@ -110,6 +124,7 @@ async def read_binary(self, path: str) -> bytes | str:
110124
return data
111125

112126
async def write_binary(self, path: str, data: bytes) -> None:
127+
self.write_binary_calls.append((path, data))
113128
if self.write_error is not None:
114129
raise self.write_error
115130
self.files[path] = data
@@ -243,6 +258,7 @@ def _make_state(
243258
root: str = "/workspace",
244259
pause_on_exit: bool = False,
245260
sandbox_url: str | None = "https://test.bl.run",
261+
extra_path_grants: tuple[SandboxPathGrant, ...] = (),
246262
) -> Any:
247263
from agents.extensions.sandbox.blaxel.sandbox import (
248264
BlaxelSandboxSessionState,
@@ -251,7 +267,7 @@ def _make_state(
251267

252268
return BlaxelSandboxSessionState(
253269
session_id=uuid.uuid4(),
254-
manifest=Manifest(root=root),
270+
manifest=Manifest(root=root, extra_path_grants=extra_path_grants),
255271
snapshot=NoopSnapshot(id="test-snapshot"),
256272
sandbox_name=sandbox_name,
257273
pause_on_exit=pause_on_exit,
@@ -349,6 +365,29 @@ async def test_write(self, fake_sandbox: _FakeSandboxInstance) -> None:
349365
await session.write("output.txt", io.BytesIO(b"written data"))
350366
assert fake_sandbox.fs.files["/workspace/output.txt"] == b"written data"
351367

368+
@pytest.mark.asyncio
369+
async def test_write_rejects_workspace_symlink_to_read_only_extra_path_grant(
370+
self,
371+
fake_sandbox: _FakeSandboxInstance,
372+
) -> None:
373+
state = _make_state(
374+
extra_path_grants=(SandboxPathGrant(path="/tmp/protected", read_only=True),)
375+
)
376+
session = _make_session(fake_sandbox, state=state)
377+
fake_sandbox.process.symlinks["/workspace/link"] = "/tmp/protected"
378+
379+
with pytest.raises(WorkspaceArchiveWriteError) as exc_info:
380+
await session.write("link/out.txt", io.BytesIO(b"blocked"))
381+
382+
assert fake_sandbox.fs.write_binary_calls == []
383+
assert str(exc_info.value) == "failed to write archive for path: /workspace/link/out.txt"
384+
assert exc_info.value.context == {
385+
"path": "/workspace/link/out.txt",
386+
"reason": "read_only_extra_path_grant",
387+
"grant_path": "/tmp/protected",
388+
"resolved_path": "/tmp/protected/out.txt",
389+
}
390+
352391
@pytest.mark.asyncio
353392
async def test_running(self, fake_sandbox: _FakeSandboxInstance) -> None:
354393
session = _make_session(fake_sandbox)

0 commit comments

Comments
 (0)