Skip to content

Commit ae2aec6

Browse files
committed
fix(sandbox): Reject symlinked LocalFile sources
Route LocalFile reads through the same pinned path handling used by LocalDir so symlinked source paths are rejected consistently. Add focused regression coverage for symlinked ancestor and leaf paths.
1 parent 106ef05 commit ae2aec6

File tree

2 files changed

+76
-3
lines changed

2 files changed

+76
-3
lines changed

src/agents/sandbox/entries/artifacts.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,17 +109,35 @@ async def apply(
109109
dest: Path,
110110
base_dir: Path,
111111
) -> list[MaterializedFile]:
112-
src = (base_dir / self.src).resolve()
112+
src = base_dir / self.src
113+
src = src if src.is_absolute() else src.absolute()
114+
local_dir = LocalDir(src=self.src.parent)
115+
rel_child = Path(self.src.name)
116+
fd: int | None = None
113117
try:
114118
checksum = sha256_file(src)
115119
except OSError as e:
116120
raise LocalChecksumError(src=src, cause=e) from e
117121
await session.mkdir(Path(dest).parent, parents=True)
118122
try:
119-
with src.open("rb") as f:
123+
src_root = local_dir._resolve_local_dir_src_root(base_dir)
124+
fd = local_dir._open_local_dir_file_for_copy(
125+
base_dir=base_dir,
126+
src_root=src_root,
127+
rel_child=rel_child,
128+
)
129+
with os.fdopen(fd, "rb") as f:
130+
fd = None
120131
await session.write(dest, f)
132+
except LocalDirReadError as e:
133+
context = dict(e.context)
134+
context.pop("src", None)
135+
raise LocalFileReadError(src=src, context=context, cause=e.cause) from e
121136
except OSError as e:
122137
raise LocalFileReadError(src=src, cause=e) from e
138+
finally:
139+
if fd is not None:
140+
os.close(fd)
123141
await self._apply_metadata(session, dest)
124142
return [MaterializedFile(path=dest, sha256=checksum)]
125143

tests/sandbox/test_entries.py

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,12 @@
1010
import agents.sandbox.entries.artifacts as artifacts_module
1111
from agents.sandbox import SandboxConcurrencyLimits
1212
from agents.sandbox.entries import Dir, File, GitRepo, LocalDir, LocalFile, resolve_workspace_path
13-
from agents.sandbox.errors import ExecNonZeroError, InvalidManifestPathError, LocalDirReadError
13+
from agents.sandbox.errors import (
14+
ExecNonZeroError,
15+
InvalidManifestPathError,
16+
LocalDirReadError,
17+
LocalFileReadError,
18+
)
1419
from agents.sandbox.manifest import Manifest
1520
from agents.sandbox.materialization import MaterializedFile
1621
from agents.sandbox.session.base_sandbox_session import BaseSandboxSession
@@ -148,6 +153,15 @@ def test_resolve_workspace_path_rejects_absolute_symlink_escape_for_host_root(
148153
assert exc_info.value.context == {"rel": escaped.as_posix(), "reason": "absolute"}
149154

150155

156+
def _symlink_or_skip(path: Path, target: Path, *, target_is_directory: bool = False) -> None:
157+
try:
158+
path.symlink_to(target, target_is_directory=target_is_directory)
159+
except OSError as e:
160+
if os.name == "nt" and getattr(e, "winerror", None) == 1314:
161+
pytest.skip("symlink creation requires elevated privileges on Windows")
162+
raise
163+
164+
151165
@pytest.mark.asyncio
152166
async def test_base_sandbox_session_uses_current_working_directory_for_local_file_sources(
153167
monkeypatch: pytest.MonkeyPatch,
@@ -168,6 +182,47 @@ async def test_base_sandbox_session_uses_current_working_directory_for_local_fil
168182
assert session.writes[Path("/workspace/copied.txt")] == b"hello"
169183

170184

185+
@pytest.mark.asyncio
186+
async def test_local_file_rejects_symlinked_source_ancestors(tmp_path: Path) -> None:
187+
target_dir = tmp_path / "secret-dir"
188+
target_dir.mkdir()
189+
nested_dir = target_dir / "sub"
190+
nested_dir.mkdir()
191+
(nested_dir / "secret.txt").write_text("secret", encoding="utf-8")
192+
_symlink_or_skip(tmp_path / "link", target_dir, target_is_directory=True)
193+
session = _RecordingSession()
194+
195+
with pytest.raises(LocalFileReadError) as excinfo:
196+
await LocalFile(src=Path("link/sub/secret.txt")).apply(
197+
session,
198+
Path("/workspace/copied.txt"),
199+
tmp_path,
200+
)
201+
202+
assert excinfo.value.context["reason"] == "symlink_not_supported"
203+
assert excinfo.value.context["child"] == "link"
204+
assert session.writes == {}
205+
206+
207+
@pytest.mark.asyncio
208+
async def test_local_file_rejects_symlinked_source_leaf(tmp_path: Path) -> None:
209+
secret = tmp_path / "secret.txt"
210+
secret.write_text("secret", encoding="utf-8")
211+
_symlink_or_skip(tmp_path / "link.txt", secret)
212+
session = _RecordingSession()
213+
214+
with pytest.raises(LocalFileReadError) as excinfo:
215+
await LocalFile(src=Path("link.txt")).apply(
216+
session,
217+
Path("/workspace/copied.txt"),
218+
tmp_path,
219+
)
220+
221+
assert excinfo.value.context["reason"] == "symlink_not_supported"
222+
assert excinfo.value.context["child"] == "link.txt"
223+
assert session.writes == {}
224+
225+
171226
@pytest.mark.asyncio
172227
async def test_local_dir_copy_falls_back_when_safe_dir_fd_open_unavailable(
173228
monkeypatch: pytest.MonkeyPatch,

0 commit comments

Comments
 (0)