Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 22 additions & 4 deletions src/agents/sandbox/util/tar_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,20 @@ def _validate_symlink_target(
if not member.issym() or allow_external_symlink_targets:
return

# Member names already reject Windows drive and separator syntax. Targets are parsed
# as POSIX, so `C:/Windows` and `..\\..\\Windows` would otherwise read as ordinary
# relative components and pass the checks below.
if PureWindowsPath(member.linkname).drive:
raise UnsafeTarMemberError(
member=member.name,
reason=f"windows drive symlink target not allowed: {member.linkname}",
)
if "\\" in member.linkname:
raise UnsafeTarMemberError(
member=member.name,
reason=f"windows path separator in symlink target: {member.linkname}",
)

target = PurePosixPath(member.linkname)
if target.is_absolute():
raise UnsafeTarMemberError(
Expand Down Expand Up @@ -157,7 +171,11 @@ def strip_tar_member_prefix(data: io.IOBase, *, prefix: str | Path) -> io.IOBase

out.seek(0)
with tarfile.open(fileobj=out, mode="r:*") as tar:
validate_tarfile(tar)
# Persisting a snapshot reads a workspace that already exists, so an external
# symlink here is something the workspace already contained rather than
# something an archive is introducing. Hydration is where that link would
# take effect, and every hydrate path rejects it.
validate_tarfile(tar, allow_external_symlink_targets=True)
out.seek(0)
return cast(io.IOBase, out)
except Exception:
Expand Down Expand Up @@ -259,7 +277,7 @@ def validate_tarfile(
skip_rel_paths: Iterable[str | Path] = (),
root_name: str | None = None,
allow_symlinks: bool = True,
allow_external_symlink_targets: bool = True,
allow_external_symlink_targets: bool = False,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject Windows-style symlink targets under strict defaults

When allow_external_symlink_targets now defaults to False, callers can rely on validate_tarfile()/safe_extract_tarfile() to reject external symlink targets, but _validate_symlink_target() only treats POSIX absolute paths and POSIX .. escapes as external. A tar containing link -> C:/Windows/System32 or link -> ..\..\Windows still passes this default and safe_extract_tarfile() will create that host-absolute/traversing link on Windows, so an untrusted archive can escape the extraction root despite the new strict default; reject Windows-drive and backslash target syntax before extraction. .agents/references/sandbox-runtime-boundary.mdL35-L39

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 9451dde. Under the strict default, C:/Windows/System32, C:\Windows\System32, ..\..\Windows and \\server\share were all accepted, because the target is parsed as a PurePosixPath while _raise_if_windows_member_path only covers member names. _validate_symlink_target now rejects a Windows drive or a backslash in the target, mirroring that helper, with a parametrized test over those four shapes.

One correction on the impact. safe_extract_tarfile has a single caller, unix_local.py:1118, and unix_local.py raises ImportError on win32 at module import, so it cannot run on a Windows host. Every other caller validates before handing the archive to a remote or containerised extractor, per the validate_tarfile docstring. So this was an incomplete guarantee in strict mode rather than a reachable escape.

) -> None:
"""Validate a workspace tar before handing it to a local or remote extractor.

Expand Down Expand Up @@ -345,7 +363,7 @@ def validate_tar_bytes(
reject_symlink_rel_paths: Iterable[str | Path] = (),
skip_rel_paths: Iterable[str | Path] = (),
root_name: str | None = None,
allow_external_symlink_targets: bool = True,
allow_external_symlink_targets: bool = False,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep venv symlink callers opted in

With this default flipped, test_safe_extract_tarfile_preserves_venv_style_symlinks still calls validate_tar_bytes(raw) and _safe_extract(raw) on an archive containing ./uv-project/.venv/bin/python3 -> /usr/local/bin/python3; both now raise UnsafeTarMemberError, and the later replacement tests that seed absolute symlinks through _safe_extract fail for the same reason. Please update the persistence/round-trip callers or fixtures to pass allow_external_symlink_targets=True if preserving those pre-existing workspace links remains expected.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, and correct. Reproduced it directly: validate_tar_bytes on that venv fixture rejects /usr/local/bin/python3 with absolute symlink target not allowed.

My Windows run masked it. Those tests already fail at os.symlink with WinError 1314, so they stayed in the failing set for a different reason and comparing failing test IDs showed no change.

Fixed in dc3cc58. The venv round-trip and the three tests that seed an absolute link before replacing it now pass allow_external_symlink_targets=True, since preserving pre-existing workspace links is the behaviour under test. _safe_extract takes the same keyword, defaulting to the safe value so a new test cannot inherit the permissive one silently.

Full suite is back to the pristine baseline: 19 failures, identical set, identical causes (17 WinError 1314, 2 RecursionError), and no UnsafeTarMemberError raised anywhere.

) -> None:
"""Validate raw workspace tar bytes with the shared safe tar policy."""

Expand All @@ -369,7 +387,7 @@ def safe_extract_tarfile(
tar: tarfile.TarFile,
*,
root: Path,
allow_external_symlink_targets: bool = True,
allow_external_symlink_targets: bool = False,
) -> None:
"""
Safely extract a tar archive into `root`.
Expand Down
6 changes: 3 additions & 3 deletions tests/extensions/sandbox/test_blaxel.py
Original file line number Diff line number Diff line change
Expand Up @@ -1178,8 +1178,8 @@ def _make_tar_with_symlink_and_file(*, symlink_name: str, target: str, file_name


class TestValidateTarBytes:
def _validate(self, raw: bytes) -> None:
validate_tar_bytes(raw)
def _validate(self, raw: bytes, *, allow_external_symlink_targets: bool = False) -> None:
validate_tar_bytes(raw, allow_external_symlink_targets=allow_external_symlink_targets)

def test_valid_tar(self) -> None:
raw = _make_tar({"hello.txt": b"content", "subdir/": None})
Expand Down Expand Up @@ -1210,7 +1210,7 @@ def test_tar_member_under_archive_symlink_rejected(self) -> None:
file_name="link.txt/nested.txt",
)
with pytest.raises(ValueError, match="descends through symlink"):
self._validate(raw)
self._validate(raw, allow_external_symlink_targets=True)

def test_corrupt_tar_rejected(self) -> None:
with pytest.raises(ValueError, match="invalid tar"):
Expand Down
94 changes: 84 additions & 10 deletions tests/sandbox/test_tar_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
safe_tar_member_rel_path,
strip_tar_member_prefix,
validate_tar_bytes,
validate_tarfile,
)


Expand Down Expand Up @@ -70,9 +71,11 @@ def _fifo(name: str) -> _Member:
return _Member(member)


def _safe_extract(raw: bytes, root: Path) -> None:
def _safe_extract(raw: bytes, root: Path, *, allow_external_symlink_targets: bool = False) -> None:
with tarfile.open(fileobj=io.BytesIO(raw), mode="r:*") as tar:
safe_extract_tarfile(tar, root=root)
safe_extract_tarfile(
tar, root=root, allow_external_symlink_targets=allow_external_symlink_targets
)


def test_safe_extract_tarfile_preserves_venv_style_symlinks(tmp_path: Path) -> None:
Expand All @@ -88,8 +91,8 @@ def test_safe_extract_tarfile_preserves_venv_style_symlinks(tmp_path: Path) -> N
_symlink("./uv-project/.venv/bin/python", "python3"),
)

validate_tar_bytes(raw)
_safe_extract(raw, tmp_path)
validate_tar_bytes(raw, allow_external_symlink_targets=True)
_safe_extract(raw, tmp_path, allow_external_symlink_targets=True)

assert (tmp_path / "uv-project" / "main.py").read_text() == 'print("snapshot smoke")\n'
assert os.readlink(tmp_path / "uv-project" / ".venv" / "lib64") == "lib"
Expand Down Expand Up @@ -165,6 +168,68 @@ def test_validate_tar_bytes_allows_internal_symlink_target_in_strict_mode() -> N
validate_tar_bytes(raw, allow_external_symlink_targets=False)


def test_validate_tar_bytes_rejects_external_symlink_target_by_default() -> None:
raw = _tar_bytes(_symlink("leak", "/etc/passwd"))

with pytest.raises(UnsafeTarMemberError, match="absolute symlink target not allowed"):
validate_tar_bytes(raw)


def test_validate_tarfile_rejects_external_symlink_target_by_default() -> None:
raw = _tar_bytes(_symlink("leak", "/etc/passwd"))

with tarfile.open(fileobj=io.BytesIO(raw), mode="r:*") as tar:
with pytest.raises(UnsafeTarMemberError, match="absolute symlink target not allowed"):
validate_tarfile(tar)


def test_safe_extract_tarfile_rejects_external_symlink_target_by_default(tmp_path: Path) -> None:
raw = _tar_bytes(_symlink("leak", "/etc/passwd"))
root = tmp_path / "root"
root.mkdir()

with tarfile.open(fileobj=io.BytesIO(raw), mode="r:*") as tar:
with pytest.raises(UnsafeTarMemberError, match="absolute symlink target not allowed"):
safe_extract_tarfile(tar, root=root)

assert not (root / "leak").exists()


@pytest.mark.parametrize(
"target",
[
"C:/Windows/System32",
r"C:\Windows\System32",
r"..\..\Windows",
r"\\server\share",
],
)
def test_validate_tar_bytes_rejects_windows_symlink_targets_by_default(target: str) -> None:
# Member names already reject this syntax. Targets are parsed as POSIX, so these
# would otherwise read as ordinary relative components.
raw = _tar_bytes(_symlink("link", target))

with pytest.raises(UnsafeTarMemberError, match="windows (drive symlink target|path separator)"):
validate_tar_bytes(raw)


def test_validate_tar_bytes_allows_windows_symlink_targets_when_opted_in() -> None:
raw = _tar_bytes(_symlink("link", "C:/Windows/System32"))

validate_tar_bytes(raw, allow_external_symlink_targets=True)


def test_strip_tar_member_prefix_still_persists_external_symlink_targets() -> None:
# Persisting reads a workspace that already contains the link. Hydration is where it
# would take effect, and every hydrate path rejects it.
raw = _tar_bytes(_dir("workspace"), _symlink("workspace/python", "/usr/bin/python3"))

normalized = strip_tar_member_prefix(io.BytesIO(raw), prefix="workspace")

with tarfile.open(fileobj=normalized, mode="r:*") as tar:
assert tar.getnames() == [".", "python"]


def test_strip_tar_member_prefix_returns_workspace_relative_archive() -> None:
raw = _tar_bytes(
_dir("workspace"),
Expand Down Expand Up @@ -200,7 +265,7 @@ def test_strip_tar_member_prefix_rewrites_pax_path_headers() -> None:
def test_safe_extract_tarfile_can_rehydrate_existing_leaf_symlink(tmp_path: Path) -> None:
raw = _tar_bytes(_symlink("link.txt", "/usr/local/bin/python3"))

_safe_extract(raw, tmp_path)
_safe_extract(raw, tmp_path, allow_external_symlink_targets=True)
assert os.readlink(tmp_path / "link.txt") == "/usr/local/bin/python3"

raw = _tar_bytes(_symlink("link.txt", "target-v2.txt"))
Expand Down Expand Up @@ -239,7 +304,7 @@ def test_safe_extract_tarfile_can_replace_existing_leaf_symlink_with_file(
tmp_path: Path,
) -> None:
raw = _tar_bytes(_symlink("python", "/usr/local/bin/python3"))
_safe_extract(raw, tmp_path)
_safe_extract(raw, tmp_path, allow_external_symlink_targets=True)

raw = _tar_bytes(_file("python", b"real file"))

Expand All @@ -252,7 +317,7 @@ def test_safe_extract_tarfile_can_replace_existing_leaf_symlink_with_directory(
tmp_path: Path,
) -> None:
raw = _tar_bytes(_symlink("bin", "/usr/local/bin"))
_safe_extract(raw, tmp_path)
_safe_extract(raw, tmp_path, allow_external_symlink_targets=True)

raw = _tar_bytes(_dir("bin"), _file("bin/python", b"real file"))

Expand Down Expand Up @@ -292,27 +357,36 @@ def test_validate_tar_bytes_rejects_members_under_archive_symlink() -> None:
)

with pytest.raises(UnsafeTarMemberError, match="descends through symlink"):
validate_tar_bytes(raw)
validate_tar_bytes(raw, allow_external_symlink_targets=True)


def test_validate_tar_bytes_can_reject_specific_symlink_path() -> None:
raw = _tar_bytes(_symlink("workspace", "/tmp/outside"))

with pytest.raises(UnsafeTarMemberError, match="symlink member not allowed: workspace"):
validate_tar_bytes(raw, reject_symlink_rel_paths={Path("workspace")})
validate_tar_bytes(
raw,
reject_symlink_rel_paths={Path("workspace")},
allow_external_symlink_targets=True,
)


def test_validate_tar_bytes_specific_symlink_rejection_normalizes_dot_prefix() -> None:
raw = _tar_bytes(_symlink("./workspace", "/tmp/outside"))

with pytest.raises(UnsafeTarMemberError, match="symlink member not allowed: workspace"):
validate_tar_bytes(raw, reject_symlink_rel_paths={"workspace"})
validate_tar_bytes(
raw,
reject_symlink_rel_paths={"workspace"},
allow_external_symlink_targets=True,
)


def test_validate_tar_bytes_specific_symlink_rejection_does_not_reject_children() -> None:
validate_tar_bytes(
_tar_bytes(_dir("workspace"), _symlink("workspace/link", "/tmp/outside")),
reject_symlink_rel_paths={"workspace"},
allow_external_symlink_targets=True,
)


Expand Down